SegmentFault 有道技术团队最新的文章
2023-11-10T19:56:13+08:00
https://segmentfault.com/feeds/blogs
https://creativecommons.org/licenses/by-nc-nd/4.0/
网易有道上线“易魔声” 开源语音合成引擎 用户可免费下载使用
https://segmentfault.com/a/1190000044380655
2023-11-10T19:56:13+08:00
2023-11-10T19:56:13+08:00
有道AI情报局
https://segmentfault.com/u/youdaotec
0
<p><strong>刚刚,我们上线了「易魔声」开源语音合成(TTS)引擎!</strong>🎉🎉🎉</p><p>「易魔声」,是一款有道自研TTS引擎,目前支持中英文双语,包含<strong>2000多种不同的音色</strong>,更有特色的情感合成功能,支持合成包含<strong>快乐、兴奋、悲伤、愤怒</strong>等广泛情感的语音。</p><p><a href="https://link.segmentfault.com/?enc=xh%2FaXpFTZjiHWl9IBIavGw%3D%3D.hPsMb3Yrq5UQ6Mqq1JGKhdgPdW1btD5KlQ7b6R285NNINbQdZzSIXcQN2IT5zkmJ71QeffBfBpG3UDFKZYbENQ%3D%3D" rel="nofollow">「易魔声」中文,网易有道,17秒</a></p><p>(我们用「易魔声」将以上这段话进行了技术合成,点击试听 )</p><p>用户可免费在开源社区GitHub进行下载使用(地址:<a href="https://link.segmentfault.com/?enc=Wc5oSY7BxVPvFF2jBZ0e5w%3D%3D.0WDq2NLPS6NFcSbeyxj8LXEYosB8ozmUmPCIkwySvNqUfI502ztuBDP6q85%2FHtLG" rel="nofollow">https://github.com/netease-youdao/EmotiVoice</a>),通过我们提供的web界面、及批量生成结果的脚本接口,轻松实现音色的情感合成与应用。</p><p><img src="/img/remote/1460000044380657" alt="图片" title="图片"><br>(<a href="https://link.segmentfault.com/?enc=yo6BEFSsHnZxWJgw56TzNw%3D%3D.5m%2BSo9ax13n2pshsJdVVTNoqGg%2BYJ9WjkMzoX%2F%2B2T%2FKw2jr2myv3id3AShe32QiG" rel="nofollow">GitHub开源界面</a>,点击绿色字可跳转)</p><p>在你过往的回忆里,是不是也有一些特别的声音,比如:偶像的声音激励人心、妈妈的声音让我们一秒回忆起小时候......声音,作为语言维度的一种,总是蕴含着人类充沛的情感表达。<strong>而富有情感的合成语音,是能够为应用和内容增色的AI功能。</strong></p><p>现在通过「易魔声」,简单通过在文本中加入情感的描述提示,开发者或者内容创作者就可以自由合成符合自己需求的带有情感的语音,比传统TTS更加自然逼真!👍</p><p>「易魔声」,是有道AI团队今年开发的一个项目。随着基于GAN等现代AI技术的语音能力越来越成熟,实现一个质量较高的TTS系统的门槛越来越低。但即使如此,中英双语的高质量、现代TTS模块还是不容易找到,要在自己的应用与内容中<strong>加入高逼真度且高度可控的语音,特别是中英双语的语音,依然比较麻烦。</strong></p><p>这也是我们将这个项目开源的初衷,希望能帮助有需求的开发者与内容创作者,并不断扩大高质量TTS的应用范围。<strong>目前该项目还处于初期阶段,期待大家在<a href="https://link.segmentfault.com/?enc=VSz5pbQgoCaCpyVnWBq6eg%3D%3D.YDNj08xeRqlAuHaPyh0eRmM6qLTOj%2FkkI%2Bsen%2FUjzGmeOFWQPQJFOAdeKGEEJ7Nu" rel="nofollow">开源网站</a>给予我们更多反馈</strong>,我们非常希望听到大家的使用体验与建议💪,<strong>欢迎扫码进群交流~</strong></p><p><img width="384" height="605" src="/img/bVdanA2" alt="" title=""></p><p>若二维码失效,可扫描下图二维码,<strong>添加我们工作人员的企业微信申请进群</strong></p><p><img width="231" height="222" src="/img/bVdanA5" alt="" title=""></p><p>借此机会,我们也邀请您了解和探索有道的更多酷炫AI技术👇</p><ul><li>您可以尝试我们的声音<a href="https://link.segmentfault.com/?enc=ucHISvzfvVlAsIGjX6GpVw%3D%3D.HkP1wyAmZlI7xHNP1jeynribkv5JA5RoFDsJP1qlr2wgTSYE3Syvo1wHiZoHBYZY" rel="nofollow">定制和声音复刻功能</a>(点击绿色字即可试用)。从用户录制到试听整个过程只需5分钟,即可完成个性化的声音定制。</li><li>您也可以和<a href="https://link.segmentfault.com/?enc=7QWUgpXavJm1LtNMixwaWQ%3D%3D.rEbhd0RlWJkY%2BaC4g%2FYr4JEPJKZ22LttvlnD6LVCRyQ%3D" rel="nofollow">Hi Echo虚拟人口语私教</a>(点击绿色字即可跳转)聊一聊。通过有道的「子曰」教育大模型、语音和虚拟人技术,Echo可以陪你轻松练习地道的英语口语。每天练习10分钟,口语水平快速提高哦。</li><li>您还可以微信搜索<strong>“有道智云体验中心”小程序</strong>。在这里,可以访问我们已经对开发者通过API等形式开放的文本和图像翻译、文字和各类图片识别、作文批改等各类AI技术。</li></ul><h3>关于有道智云</h3><p>有道智云AI开放平台,是网易有道旗下一站式人工智能服务提供商,为开发者、企业和政府机构等提供自然语言翻译、文字识别、OCR、语音识别等服务以及行业解决方案,致力于提供安全、可靠和高效的云服务。 </p><p>联系电话:010-8255-8901;商务合作:AIcloud_B<a href="mailto:usiness@corp.youdao.com">usiness@corp.youdao.com</a></p><p>想了解更多关于有道人工智能的内容,可访问“有道智云”官网👉<a href="https://link.segmentfault.com/?enc=sy0%2F8HuLJZlhsM9jIuMw3Q%3D%3D.JHBG1JcRHUsXRYZsSIKR0oBHWk5UHI972bByGvXQGhE%3D" rel="nofollow">https://ai.youdao.com.</a></p>
2022 中国技术品牌影响力企业榜发布,网易有道入选30强之列
https://segmentfault.com/a/1190000043261679
2023-01-06T14:25:33+08:00
2023-01-06T14:25:33+08:00
有道AI情报局
https://segmentfault.com/u/youdaotec
1
<p>2023年 1 月 4 日,中国技术先锋年度评选 | 2022 中国技术品牌影响力企业榜单正式发布,网易有道凭借科技创新实力入选榜单30 强之列。<br><img src="/img/bVc5s4x" alt="" title=""></p><p>“2022 中国技术先锋年度榜单”由中国领先的新一代开发者社区 SegmentFault 思否发布,旨在推动科技企业进一步提升技术品牌声量,加速企业生态建设。榜单依托社区内数百万开发者用户数据分析、各科技企业在国内技术领域的行为及影响力指标,最终评选出 30 家上榜企业。<br>作为中国领先的智能学习公司,网易有道基于自研的 AI 核心技术,结合对学习场景的深刻理解,打造了一系列深受用户喜欢的学习产品和服务,现已开拓硬件、课程、教育信息化等多元业务,包括网易有道词典、有道词典笔、有道AI学习机等智能学习工具,致力帮助用户实现高效学习。<br>新的一年,网易有道将继续加强科技生态建设,服务广大开发者,以领先技术打造更多高质量科技产品,保持高质量技术内容输出,和开发者共同成长。</p><p>附:《2022 中国技术品牌影响力企业》<br>图:榜单长图<br><img src="/img/bVc5z9I" alt="" title=""></p>
论文神器上新,有道写作正式支持LaTeX了!
https://segmentfault.com/a/1190000043118858
2022-12-20T15:40:37+08:00
2022-12-20T15:40:37+08:00
有道AI情报局
https://segmentfault.com/u/youdaotec
0
<p>近日,有道写作正式上线LaTeX论文支持功能,让长期困扰科研写作者的LaTeX纠错难题迎刃而解。</p><p>想要在国际期刊上发论文,除了扎实的科研能力、一流的英文写作水平之外,LaTeX也几乎成了必备技能。LaTeX是一种基于 TeX 的高品质排版系统,由美国计算机科学家莱斯利·兰伯特开发,适用于生成高印刷质量的科技和数学、物理文档,尤其擅长于复杂表格和数学公式的排版。</p><p>而针对这个LaTeX系统的英文写作,也让科研和研究人员头疼。据了解,以前用户的LaTeX文本想要批改,大都是直接粘贴LaTeX文本,但是服务会给出大量奇怪或无效的纠错结果,同时也会忽略掉一些真正的语法错误。还有一种更准确的方法是,有用户会选择将LaTeX文本中的纯文本片段取出,粘贴到系统中进行批改。 这种做法由于标签会被当成错误,要么是因为标签的影响,真实的错误也被漏掉了。<br><img src="/img/remote/1460000043118860" alt="图片" title="图片"><br>所有人抓狂的难题,可以使用有道写作的LaTeX功能轻松解决啦!近期,有道写作上线了针对LaTeX的批改新功能——在综合了多种有效方案后,有道将LaTeX文本相关的语法标签进行分门别类的梳理,生成纯文本后将其送入到纠错系统中纠错,最后再恢复为LaTeX文本。在保证质量的同时,不影响文章格式,渲染效果。这是行业内首个针对LaTeX使用场景推出的全新功能,将大大提高科研人士的论文写作便捷性和准确性。</p><p>有道AI团队技术人员随机测试的效果也有力证明了该新功能的错误率降低80%以上。技术人员从arxiv上随机抽取了86篇含有LaTeX源文的论文,将其分拆为共计1000个LaTeX的片段,分别使用纯文本的批改算法和支持LaTeX的批改算法进行批改。最终结果显示,纯文本批改时错误的数量总计为47628个,LaTeX批改时的错误数量仅为7941个,错误率大大降低。<br><img src="/img/remote/1460000043118861" alt="图片" title="图片"></p><p><img src="/img/remote/1460000043118862" alt="图片" title="图片"><br>网易有道是全国领先的智能学习公司,凭借将AI技术应用于学习场景的核心能力,陆续推出有道词典、有道翻译等等为大众熟知的产品及服务,分别助力英语学习者攻克“听、说、读、写”各个环节。</p><p>有道写作,定位于“更懂中国人的英文写作神器”,是为中国人外语学习“写”这一细分需求而深耕的产品。针对写英语的刚需人群学生,有道写作深入调研了小学、初中、高中、大学四六级、考研、出国(雅思、托福、GRE、PTE等等)的不同需要,结合用户需求开发了写作批改、优化、润色和打分的能力。而面向越来越大众的“科研写作”场景,有道写作在行业内率先上线LaTeX论文支持功能,也体现了网易有道在AI落地应用场景上的积极尝试与探索。</p><p>“不断发现中国英语学习者的新需求,并通过AI科技创新的手段去解决,让科技真正地为用户带来便捷,是我们的使命。未来我们会根据用户的反馈不断迭代新功能,让各个场景下的英文写作都变得越来越简单。”有道写作负责人表示。</p>
有道 AI 论文入选 COLING 2022,引入条件掩码语言模型用于神经机器翻译数据增强
https://segmentfault.com/a/1190000042548858
2022-09-27T08:51:10+08:00
2022-09-27T08:51:10+08:00
有道AI情报局
https://segmentfault.com/u/youdaotec
0
<p>计算语言学国际会议 COLING 2022 是计算语言学和自然语言处理领域的重要国际会议,由国际计算语言学委员会(International Committee on Computational Linguistics,ICCL)主办。</p><p><strong>有道 AI 在机器翻译方向的研究论文,被 COLING2022 以长文形式正式录用发表。</strong></p><p><strong>题目:</strong>Semantically Consistent Data Augmentation for Neural Machine Translation via Conditional Masked Language Model<br><strong>作者:</strong>程桥,黄瑾,段亦涛</p><p>论文全文请见文末<strong>「阅读原文」</strong></p><h2>研究背景</h2><p>神经机器翻译(NMT)通常需要大量的双语平行语料进行训练,在小数据的训练集上非常容易过拟合。高质量的双语平行语料比较难获取,通常人工标注语料需要较高的成本。数据增强方法是一种有效扩充数据规模的技术,并且在一些领域取得了显著的效果。比如在视觉领域,训练数据通常会使用诸如裁剪,翻转,弯曲或者颜色变换等方法来扩充。</p><p>虽然数据增强方法在视觉领域成为了训练神经网络模型的一项基本的技术,但是在自然语言处理领域,这项技术还没有得到很好的应用。</p><p><strong>本论文主要研究了神经机器翻译(NMT)中使用词替换进行数据增强的技术,</strong>词替换技术通过替换现有的平行语料库句对中的词汇达到数据扩充的目的。在使用数据增强方法时,我们观察到增强的数据样本如果保留了正确的标签信息,那么就可以有效扩充训练的数据规模,从而提升模型的效果。这个属性我们称为<strong>语义一致性(semantic consistency)</strong>。</p><p>在神经机器翻译系统中,训练数据是以句对形式存在的,包含源端句子和目标端句子。语义一致性要求源端和目标端的句子在各自的语言中都是流利的且语法正确的,同时还要求目标端句子应该是源端句子的高质量翻译。</p><p>现有的词替换方法通常是对源端和目标端句子中的单词进行交换、删除或随机的替换。由于自然语言处理的离散性,这些变换不能保持语义的一致性,通常它们可能会削弱双语句子的流畅性或者破坏句对之间的关联性。</p><p>我们可以看一个<strong>案例:</strong></p><p><img src="/img/bVc2G3m" alt="" title=""></p><p>这个例子是英德平行语料库中的一对句子和一些对英文端进行词替换得到的句子。Case 1 和 2 都是有问题的替换方式,前者虽然与替换的词保持了同样的意思,但在语法上是不正确的,后者虽然语法正确,但和德语句子不是互译的关系了。Case 3因为是一个语法正确,语义也保持一致,所以是一个好的增强样本。</p><p>在生成增强数据的过程中,利用上下文和标签信息可以实现更好的增强效果。我们引入了条件掩码语言模型(CMLM) 用于机器翻译的数据增强。掩码语言模型可以同时利用句内双向上下文信息,而 CMLM 是它的一个增强版本,它可以利用更多的标签信息。我们展示了 CMLM 可以通过迫使源端和目标端在进行词替换时保持语义一致性,从而能够生成更好的替换词分布。</p><p>此外,为了增强多样性,我们结合了软性数据增强(Soft Cotextual Data Augmentation)方法,这个方法使用词表上的一个分布来替换具体的词。</p><p>论文中提出的方法在4个不同规模的数据集上进行了实验,结果都表明<strong>该方法相比于之前的词替换技术更有效,翻译质量更高。</strong></p><h2>方法介绍</h2><p>我们的目标是改进机器翻译训练中的数据增强方法,使得在增强的过程中,可以保留源句和目标句的语义以及它们之间的跨语言互译关系。</p><p>为达到这个目标,我们引入了<strong>条件掩码语言模型(CMLM)</strong> ,它可以生成上下文相关的替换词分布,从中我们可以选择给定单词最好的替代词。CMLM模型是MLM的一个变种形式,它在预测掩码时会结合标签信息。</p><p>在机器翻译场景中,<strong>CMLM遵循两点要求</strong>:</p><ul><li>预测掩码时会同时以源端和目标端为条件;</li><li>在CMLM训练时,只会掩蔽源端的部分词或目标端的部分词,但不会同时掩蔽源端和目标端。</li></ul><p>实际训练中可以拼接源端和目标端句子,然后随机掩蔽15%的源端单词,训练一个CMLM去预测掩蔽的源端单词。同样,也可以随机掩蔽15%的目标端单词,训练一个的CMLM基于拼接的双语句子去预测掩蔽的目标端单词。这种依赖双语信息预测某一端掩蔽词的特点是使用CMLM预测词做数据增强能够保持语义一致性的关键。</p><p>当使用上述方法训练好CMLM模型后,就可以用来扩充训练用的双语语料了。对于训练的双语语料,掩蔽源端或目标端的某些词,使用CMLM预测出可能候选词的分布,然后在分布中采样某个词替换掉对应位置的词。</p><p>由于 CMLM 同时结合了源端和目标端的信息,模型预测的词能很好地保持双语的语义一致性。这种直接替换的方法是比较费时的,如果需要减少采样的方差,就需要生成足够多的候选。为了提升这里的效率,我们结合了<strong>软性数据增强</strong>的方法。</p><p>软性数据增强不采样具体的词,而是根据预测的分布计算在词表上的词向量期望,使用这种软性的词向量替换真实的词向量表示。软性的词向量表示这样计算:</p><p><img src="/img/bVc2G3n" alt="" title=""></p><p>在神经机器翻译训练中使用 CMLM 做数据增强架构如下图所示。这里有两个独立的 CMLM,分别用来增强源端和目标端。我们使用预训练的多语言 BERT 初始化 CMLM,使用前述方法进行微调。在翻译模型训练过程中,CMLM 部分参数固定不动,按一定概率使用 CMLM 生成的软性词向量替换真实的词向量参与机器翻译模型的训练。我们探索了不同替换概率对翻译模型质量的影响。</p><p><img src="/img/bVc2G3p" alt="" title=""></p><h2>实验及结果</h2><p>为了验证论文提出的方法的效果,我们在三个较小规模的数据集:包括IWSLT2014 德语、西班牙语、希伯来语翻译到英语,及一个较大规模的数据集:WMT14 英语翻译到德语,进行了实验验证。</p><p>我们将此方法与其他几种数据增强方法进行了比较,包括一些规则的词替换方法,例如单词交换、删除、随机替换,以及两个利用语言模型进行替换的方法。我们还将论文中的方法与句子级增强方法 mixSeq 进行了比较。我们的基线系统是没有使用任何数据增强的系统。</p><p>为了对比,我们使用CMLM进行了两组数据增强实验:第一组使用前文描述的软性词向量替换的方法,第二种使用传统的采样替换方法,替换词根据CMLM的预测采样产生。</p><p>两种方法都同时应用到源端和目标端,并且使用相同的掩码概率gamma = 0.25,这是我们发现的最优配置。</p><p>实验结果如下图所示:</p><p><img src="/img/bVc2G3q" alt="" title=""></p><p>从表格中的结果可以看出,<strong>两种使用CMLM做数据增强的方法都显著优于基线系统</strong>,其中CMLM软性词向量增强的方法在所有任务上都取得了最优的结果。特别是在 WMT 英译德上取得了 1.9 BLEU 的提升。</p><p>除了在公共语料库上的实验,<strong>我们还将该方法应用到有道翻译的线上系统中</strong>。有道线上翻译系统(<a href="https://link.segmentfault.com/?enc=jUm4OQ4uWsKrseolLIbRyw%3D%3D.xS6ZCk58YAtaVqa6FvBpSHFVP8r8Rgi3f4w65HCUNZQ%3D" rel="nofollow">http://fanyi.youdao.com</a>)使用近亿句对语料训练,模型大小接近5亿参数量,并使用了多种优化方法,在多个测试集上优于其他产品。在这样领先的商业机器翻译系统上,我们的方法也取得了显著的提升效果。</p><h2>实际应用</h2><p>自2007年推出网易有道词典以来,有道 AI 团队持续多年在机器翻译技术上发力。2017年推出有道神经网络翻译引擎(YNMT),使得翻译质量得到质的飞跃。</p><p>除网易有道词典之外,有道神经网络翻译技术已经应用于有道翻译官、有道少儿词典、U-Dictionary 等丰富的学习类工具 App 当中,为不同需求的用户提供高质量、可信赖的翻译和语言学习服务。</p><p><img src="/img/bVc2G3r" alt="" title=""></p><p>除软件外,YNMT 技术也已应用于<strong>有道词典笔、有道智能学习灯、有道 AI 学习机、有道听力宝</strong>等多款智能学习硬件中,并针对硬件产品进行了高性能、低功耗的定制化设计,实现了「 毫秒级点查 」、「 0.5s 指尖查词」等核心功能。</p><p><img src="/img/bVc2G3s" alt="" title=""></p><p>基于自研的 AI 核心技术,结合对学习场景的深刻理解,网易有道已经开拓学习硬件与工具、素养类课程、大学与职场课程、教育信息化等多种业务,致力于帮助用户实现高效学习。未来,有道 AI 会继续进行前沿技术的前瞻性研究,并推动其在产品和真实场景中的落地。</p><p>论文全文请见 「阅读原文」:<br><a href="https://link.segmentfault.com/?enc=16YWh3O57ypCU5%2FlSNFjoA%3D%3D.ka%2F8Pqz7gIYhFO51X32XsjMrqQHfgBgF4S9sc0qy%2FXouo6drm0XxXcW46Bq6xvEg" rel="nofollow">阅读原文</a></p>
杰夫 · 迪恩:《深度学习的黄金十年:计算系统与应用》
https://segmentfault.com/a/1190000042478316
2022-09-14T14:49:31+08:00
2022-09-14T14:49:31+08:00
有道AI情报局
https://segmentfault.com/u/youdaotec
0
<blockquote>假如十年前,你向别人介绍人脸识别、无人驾驶、对话机器人,也许会被当作疯子。然而今天,随着AI技术的发展,这一切都逐渐成真。<br>即便五年前,有道推出有道神经网络翻译引擎(YNMT),使得翻译质量得到质的飞跃时,大家对机器翻译的质量仍然心存疑虑。但今天,人们甚至已经开始讨论未来是否还会存在翻译这个职业。<br>过去十年是人工智能快速发展的十年,也是从实验室走向工业界的十年。近期拜读AI领域标杆人物、Google 人工智能负责人杰夫·迪恩的长文,不免有些共鸣与感悟,也对继续投身 AI 技术发展的下一个十年充满期待。<br>我们用有道神经网络翻译引擎(YNMT)将全文做了翻译,以飨读者。大家也可以感受一下,当前机器翻译的效果。 <br>——网易有道首席科学家 段亦涛</blockquote><h3>摘要</h3><p>自计算机诞生之初,人类就梦想着能够创造出“会思考的机器”。1956年,约翰·麦卡锡(John McCarthy)在达特茅斯学院(Dartmouth College)组织了一个研讨会,会上一群数学家和科学家聚在一起,“研究如何让机器使用语言,形成抽象概念,解决现在只留给人类的各种问题,并提高自己。”讲习班与会者乐观地认为,几个月的集中努力将在这些问题上取得实际进展。</p><p>几个月的时间表被证明过于乐观。在接下来的50年里,创造人工智能系统的各种方法层出不穷,包括基于逻辑的系统、基于规则的专家系统和神经网络编码世界逻辑规则并使用这些规则的方法被证明是无效的。以Cyc项目为最突出的例子,手工将数百万条人类知识整理成机器可读的形式,被证明是一项非常劳动密集型的工作,在使机器自主学习方面没有取得显著进展从真正的生物神经网络中汲取灵感的人工神经网络,在这段时间里似乎是一种很有前途的方法,但最终在20世纪90年代失宠。虽然他们能够在玩具规模的问题上得出令人印象深刻的结果,但他们无法在当时的现实世界问题上得出有趣的结果。</p><p>1990年,作为一名本科生,我对神经网络着迷,觉得它们似乎是创造智能机器的正确抽象,并相信我们只需要更多的计算能力,就可以使更大的神经网络解决更大、更有趣的问题。我做了一篇关于神经网络并行训练的本科论文,相信如果我们可以使用64个处理器而不是一个处理器来训练一个神经网络,那么神经网络就可以解决更多有趣的任务然而事实证明,相对于1990年的计算机,我们需要大约100万倍的计算能力,而不是64倍,才能让神经网络在具有挑战性的问题上开始取得令人印象深刻的进展!</p><p>然而,大约从2008年开始,由于摩尔定律,我们开始拥有如此强大的计算机,神经网络开始复苏,并成为最有前途的创造能看、能听、能理解和能学习的计算机的方式(以及将这种方法重新命名为“深度学习”)。</p><p>从2011年到本文撰写时(2021年)的这十年,在实现1956年达特茅斯研讨会设定的目标方面取得了显著进展,机器学习(ML)和人工智能现在在许多领域都取得了巨大的进步,为新的计算体验和交互创造了机会,并极大地扩展了世界上可以解决的问题集。</p><p><strong>本文主要关注三个方面</strong>:推动这一进展的计算硬件和软件系统;过去十年中机器学习的一些激动人心的应用实例;以及我们如何创造更强大的机器学习系统,以真正实现创造智能机器的目标。</p><h3>1.人工智能的硬件和软件</h3><p>与通用的计算机代码(比如你每天运行文字处理器或网络浏览器时可能会用到的软件)不同,深度学习算法通常是由组成少量线性代数操作的不同方式构建的:矩阵乘法、向量点积和类似的操作。由于这种有限的操作词汇表,我们有可能制造出专门支持这类计算的计算机或加速器芯片。这种专门化实现了相对于通用中央处理单元(cpu)的新的效率和设计选择,后者必须运行更广泛的各种算法。</p><p>在21世纪初,一些研究人员开始研究使用图形处理单元(gpu)来实现深度学习算法。尽管这些设备最初是为绘制图形而设计的,但研究人员发现,它们也非常适合深度学习算法,因为与cpu相比,它们具有相对较高的浮点计算率。2004年,计算机科学家Kyoung-Su Oh和Keechul Jung展示了使用GPU的神经网络算法的近20倍的改进。2008年,计算机科学家Rajat Raina和同事展示了使用GPU的速度比一些无监督学习算法的最佳cpu实现的速度高72.6倍。</p><p>这些早期的成就继续建立,因为在gpu上训练的神经网络在各种各样的计算机视觉比赛中胜过其他方法。随着深度学习方法在图像识别、语音识别和语言理解方面的显著改进,以及越来越多的计算密集型模型(在更大的数据集上训练)不断展示改进的结果,机器学习领域真正开始腾飞。计算机系统架构师们开始研究如何将深度学习模型扩展到计算密集程度更高的地方。早期的一种方法使用大规模分布式系统来训练单一的深度学习模型。谷歌研究员开发了 DistBelief 框架,这是一个能够使用大规模分布式系统来训练单个神经网络的软件系统。使用DistBelief,研究人员能够训练一个单一的无监督神经网络模型,该模型比以前的神经网络大两个数量级。该模型是在 YouTube 视频的大量随机帧集合上训练的,有了一个大的网络和足够的计算和训练数据,它证明了模型中的单个人工神经元(神经网络的构建模块)可以学习识别像人脸或猫这样的高级概念,尽管除了原始图像的像素外,从来没有给过关于这些概念的任何信息。</p><p>这些成功促使系统设计师设计出比gpu更适合和匹配深度学习算法需求的计算设备。为了构建专门的硬件,深度学习算法有两个非常好的特性。首先,它们对降低的精度非常宽容。许多数值算法需要32位或64位浮点表示来保证计算的数值稳定性,与此不同,深度学习算法在训练期间使用16位浮点表示(神经网络从观察中学习的过程),在推理期间使用8位甚至4位整数定点表示(神经网络从输入生成预测或其他输出的过程)通常都没问题。与使用更高精度的乘法器相比,使用较低精度的乘法器可以在相同的芯片区域放置更多的乘法器,这意味着芯片每秒可以执行更多的计算。其次,深度学习算法所需的计算几乎完全由密集矩阵或向量上不同序列的线性代数运算组成,如矩阵乘法或向量点积。这让我们看到,制造专门用于低精度线性代数计算的芯片和系统,可以在每美元和每瓦特的性能方面带来很大的好处。这方面的早期芯片是谷歌的第一个张量处理单元(TPUv1),它针对用于深度学习推理的8位整数计算,在速度和性能上比当代cpu和gpu提高了1到2个数量级这些芯片的部署使谷歌在语音识别精度、语言翻译和图像分类系统方面取得了显著的改进。后来的TPU系统由自定义芯片和更大规模的系统组成,这些芯片通过高速自定义网络连接到pods(用于训练深度学习模型的大规模超级计算机)像NVIDIA这样的GPU制造商开始为较低精度的深度学习计算量身定制后来的设计,风投资金资助的创业公司如雨后雨来,构建各种深度学习加速器芯片,其中GraphCore、Cerebras、SambaNova和Nervana是其中最知名的一些。</p><p>随着 gpu 和其他面向 ml 的硬件崛起,研究人员开发了开源软件框架,使表达深度学习模型和计算变得容易。这些软件框架仍然是关键的推动者。今天,开源框架帮助广泛的研究人员、工程师和其他人推动深度学习研究,并将深度学习应用到范围非常广泛的问题领域(其中许多将在下面讨论)。一些最早的框架,如2003年开始开发的Torch,从早期的数学工具(如MatLab和NumPy)中汲取灵感Theano是2010年开发的,是一个早期的面向深度学习的框架,包括自动符号区分自动微分是一个有用的工具,它大大简化了许多基于梯度的机器学习算法的表达,例如随机梯度下降(一种通过比较实际输出和期望输出,并在误差梯度的方向上对模型参数进行小的调整来纠正输出中的错误的技术)。DistBelief和Caffe是在2010年代早期开发的框架,强调规模和性能。</p><p>TensorFlow是一个允许表达机器学习计算的框架它是谷歌在2015年开发和开源的,并结合了早期框架,如Theano和DistBelief的想法,TensorFlow是针对各种各样的系统设计的,允许ML计算运行在桌面计算机、手机、数据中心的大规模分布式环境和web浏览器上,并针对各种各样的计算设备,包括cpu、gpu和tpu。该系统已被下载超过5000万次,是世界上最受欢迎的开源软件包之一。它使世界各地大大小小的个人和组织能够大量使用机器学习。</p><p>2018年发布的JAX是一个流行的面向python的开源库,它结合了复杂的自动区分和底层的XLA编译器,TensorFlow也使用它来高效地将机器学习计算映射到各种不同类型的硬件上。</p><p>像Tensor-Flow和PyTorch这样的开源机器学习库和工具的重要性再怎么强调也不为过。它们允许研究人员在这些框架上快速尝试想法并表达它们。随着世界各地的研究人员和工程师更容易建立在彼此的工作上,整个领域的进展速度加快了!</p><h3>2.研究爆炸</h3><p>随着研究的进步,gpu和tpu等面向ml的硬件的计算能力不断增强,以及Tensor-Flow和PyTorch等开源机器学习工具的广泛采用,机器学习及其应用领域的研究产出出现了巨大的增长。一个强有力的指标是发布到arXiv机器学习相关类别的论文数量,arXiv是一个流行的论文预印本托管服务,2018年发布的论文预印本数量是2009年的32倍多(每两年增长一倍多)现在,每天有超过100篇与机器学习相关的研究论文发布在arXiv上,而且这种增长没有任何放缓的迹象。</p><h3>3.应用爆炸</h3><p>计算能力的变革性增长、机器学习软件和硬件系统的进步,以及机器学习研究的激增,都导致了机器学习应用在科学和工程的许多领域的激增。通过与气候科学和医疗保健等关键领域的专家合作,机器学习研究人员正在帮助解决对社会有益、促进人类进步的重要问题。我们确实生活在一个激动人心的时代。</p><p>神经科学是机器学习加速科学进步的一个重要领域。2020年,研究人员研究了一只苍蝇的大脑,以更多地了解人类大脑的工作方式。他们建立了一个连接体,一个突触分辨率级别的整个苍蝇大脑连接图但如果没有机器学习和我们现在拥有的计算能力,这将需要很多年。例如,在20世纪70年代,研究人员花了大约10年的时间,煞费苦心地绘制了蠕虫大脑中的大约300个神经元。相比之下,苍蝇的大脑有10万个神经元,而老鼠的大脑(机器学习辅助连接组学的下一个目标)有大约7000万个神经元。人类的大脑包含大约850亿个神经元,每个神经元大约有1000个连接。幸运的是,基于深度学习的计算机视觉技术的进步,现在可以加快这个之前庞大的过程。今天,依靠机器学习,你可以使用交互式3d模型自己探索苍蝇的大脑!</p><h4>3.1分子生物学</h4><p>机器学习还可以帮助我们更多地了解我们的基因构成,最终更有效地解决基于基因的疾病。这些新技术允许科学家通过更精确的模拟、估计和数据分析来更快地探索潜在实验的前景。一种名为DeepVariant的开源工具可以更准确地处理来自DNA测序机的原始信息(其中包含读取基因序列的物理过程引入的错误),并通过卷积神经网络对其进行分析,相对于参考基因组数据,更准确地识别序列中真正的基因变异。一旦确定了遗传变异,深度学习还可以帮助分析遗传序列,更好地理解导致特定健康或其他结果的单个或多个DNA突变的遗传特征。例如,达纳-法伯癌症研究所(Dana-Farber Cancer Institute)领导的一项研究,在2367名癌症患者队列中,将导致前列腺癌和黑色素瘤的基因变异的诊断率提高了14%。</p><h4>3.2医疗保健</h4><p>机器学习还提供了帮助检测和诊断疾病的新方法。例如,当应用于医学图像时,计算机视觉可以帮助医生诊断一些严重的疾病,比医生自己诊断更快速、更准确。</p><p>一个令人印象深刻的例子是深度神经网络正确诊断糖尿病视网膜病变的能力,通常与人类眼科医生的水平相当。这种眼疾是增长最快的可预防失明原因(预计到2040年将影响6.42亿人)。</p><p>深度学习系统还可以像训练有素的放射科医生一样或更好地帮助检测肺癌。同样的道理也适用于乳腺癌、皮肤病和其他疾病对病历进行顺序预测的应用可以帮助临床医生确定慢性疾病的可能诊断和风险水平。</p><p>今天的深度学习技术也让我们更准确地了解疾病的传播方式,让我们有更好的机会预防疾病。机器学习帮助我们模拟复杂事件,如全球COVID-19大流行,这需要全面的流行病学数据集、开发新的可解释模型和基于代理的模拟器,为公共卫生应对提供信息。</p><h4>3.3天气、环境和气候变化</h4><p>气候变化是当前人类面临的最大挑战之一。机器学习可以帮助我们更好地了解天气和环境,特别是在预测日常天气和气候灾害方面。</p><p>在天气和降水预报方面,计算密集型的基于物理的模型,如美国国家海洋和大气管理局的高分辨率快速刷新(HRRR),长期以来一直占据主导地位,然而基于机器学习的预测系统在短时间尺度上的预测比HRRR更准确,具有更好的空间分辨率和更快的预测计算。</p><p>对于洪水预测,神经网络可以为世界各地的河流系统建模(一种被称为HydroNets的技术),从而产生更准确的水位预测例如,利用这项技术,当局可以更快地向印度和孟加拉国的2亿多人发出洪水警报。</p><p>机器学习还能帮助我们更好地分析卫星图像。我们可以快速评估自然灾害后的损害(即使有有限的先前卫星图像),了解野火的影响和程度,并改善生态和野生动物监测。</p><h4>3.4机器人技术</h4><p>物理世界是混乱的,充满了意想不到的障碍、滑动和破碎。这使得创造出能够在厨房、办公室和道路等混乱的真实环境中成功操作的机器人变得相当具有挑战性(工业机器人已经对世界产生了重大影响,可以在工厂装配线等更可控的环境中操作)。要对真实的物理任务进行编码或编程,研究人员需要预测机器人可能遇到的所有可能情况。机器学习通过结合强化学习、人类演示和自然语言教学等技术,有效地训练机器人在现实环境中有效地操作。机器学习还提供了一种更灵活、适应性更强的方法,机器人可以学习执行抓取或行走任务的最佳方式,而不是被锁定在硬编码的假设中。</p><p>一些有趣的研究技术包括与远程机器人导航相结合的自动强化学习,教机器人遵循自然语言指令(多种语言!),以及应用零射击模仿学习框架来帮助机器人更好地导航模拟和真实环境。</p><h4>3.5可用性</h4><p>我们很容易认为看到美丽的画面,听到喜欢的歌曲,或与心爱的人说话是理所当然的。然而,超过10亿人无法通过这些方式接触世界。机器学习通过将这些信号(视觉、听觉、语音)转化为有无障碍需求的人可以很好地管理的其他信号,从而改善无障碍环境,使人们能够更好地接触到周围的世界。一些应用的例子包括语音到文本的转录,当某人参与对话时的实时转录,以及帮助视障用户识别他们周围环境的应用。</p><h4>3.6因材施教</h4><p>机器学习还可以用于创建工具和应用程序,以帮助个性化学习。这将带来深远的好处,最初的例子包括早期儿童阅读指导,如谷歌Read Along(前身为Bolo),它正在帮助世界各地的儿童学习各种不同语言的阅读,和机器学习工具,如苏格拉底,可以通过给他们直观的解释和更详细的信息,他们正在努力学习的概念,在各种各样的科目,如数学、化学、还有文学由语音识别、现实的语音输出和语言理解支持的个性化学习有潜力改善世界各地的教育成果。</p><h4>3.7计算机辅助创造力</h4><p>深度学习算法显示出以复杂和创造性的方式转换图像的惊人能力,使我们能够轻松地创建莫奈风格的宇宙飞船或爱德华·蒙克风格的金门大桥。通过一种艺术风格转移的算法(由机器学习研究员Leon Gatys和同事开发),神经网络可以取一张真实世界的图像和一幅画的图像,并自动呈现画家风格的真实世界的图像。</p><p>OpenAI的DALL·E让用户可以使用文本描述图像(“牛油果形状的扶手椅”或“一个阁楼卧室,床头柜旁边有一张白色的床,床旁边有一个鱼缸”),并生成具有自然语言描述所表达的属性的图像,为艺术家和其他创作者提供了复杂的工具,以快速创建他们头脑中的图像。</p><p>以机器学习为动力的工具也在帮助音乐家以前所未有的方式进行创作除了“技术”,这些计算的新用途可以帮助任何人创造新的和独特的声音、节奏、旋律,甚至是一种全新的乐器。</p><p>不难想象,未来的工具可以交互式地帮助人们创造我们精神意象的惊人表现——“给我画一个海滩……不,我希望是晚上……满月的时候…还有一只长颈鹿妈妈和一个婴儿在冲浪者旁边从水里出来”——通过与我们的计算机助手互动交谈。</p><h4>3.8重要组成部分</h4><p>联邦学习是一种强大的机器学习方法,可以保护用户隐私,同时利用许多不同的客户端(如移动设备或组织)协作训练一个模型,同时保持训练数据的分散这使得在大规模学习系统中具有优越隐私属性的方法成为可能。</p><p>研究人员通过开发自适应学习算法、在联邦设置中模仿集中式算法的技术、对互补的密码图形协议的大幅改进等,继续推动联邦学习的艺术水平。</p><h4>3.9Transformer</h4><p>自人工智能领域诞生以来,语言一直是该领域发展的核心,因为语言的使用和理解在我们的日常生活中无处不在。因为语言涉及符号,所以一开始很自然地促使人工智能采用符号方法。但多年来,人工智能研究人员逐渐意识到,更多的统计或基于模式的方法可以产生更好的实际用途。正确的深度学习类型可以有效地表示和操纵语言的分层结构,用于各种现实世界的任务,从语言之间的翻译到图像标记。谷歌和其他地方在这一领域的大部分工作现在依赖于变形金刚,这是一种最初为语言问题开发的特殊风格的神经网络模型(但越来越多的证据表明,它们也可以用于图像、视频、语音、蛋白质折叠和其他各种各样的领域)。</p><p>已经有几个在科学设置中使用变形器的有趣例子,例如训练蛋白质序列以找到编码有意义的生物特性的表示,通过语言建模生成蛋白质,bio-BERT用于生物医学数据中的文本挖掘(使用预先训练的模型和训练代码),嵌入科学文本(使用代码),以及医学问题回答。计算机科学家Maithra Raghu和Eric Schmidt对深度学习用于科学发现的方法进行了全面的回顾。</p><h4>3.10计算机系统的机器学习</h4><p>研究人员还将机器学习应用于核心计算机科学和计算机系统本身的问题。这对机器学习和计算基础设施研究来说是一个令人兴奋的良性循环,因为它可以加速我们应用到其他领域的所有技术。事实上,这一趋势正在催生全新的会议,如 MLSys 基于学习的方法甚至被应用于数据库索引、学习排序算法、编译器优化、图优化和内存分配。</p><h3>4.机器学习的未来</h3><p>在ML研究社区中出现了一些有趣的研究线索,如果把它们结合起来,可能会更加有趣。</p><p>首先,在稀疏激活模型上的工作,比如稀疏门控混合专家模型,展示了如何构建非常大的容量模型,其中对于任何给定的例子,只有模型的一部分被“激活”(比如,2048个专家中只有2或3个专家)这些模型中的路由函数与不同的专家同时联合训练,使路由函数了解哪些专家擅长于哪类示例,而专家同时学习针对所给出的示例流的特征进行专门研究。这与现在大多数的ML模型形成了鲜明的对比,在ML模型中,每个实例都要激活整个模型。研究科学家Ashish Vaswani和他的同事们表明,这种方法在训练时效率提高了约9倍,在推理时效率提高了约2.5倍,而且更准确(+1 BLEU点,对于语言翻译任务来说,这是一个相对较大的准确性提高)。</p><p>其次,在自动化机器学习(AutoML)方面的工作,如神经结构搜索或进化结构搜索等技术可以自动学习有效的结构和机器学习模型或组件的其他方面,以优化给定任务的准确性,通常涉及运行许多自动化实验,每一个可能涉及大量的计算。</p><p>第三,在几个到几十个相关任务的适度规模上进行多任务训练,或者从一个相关任务的大量数据上训练的模型转移学习,然后对一个新任务的少量数据进行微调,已经被证明对各种各样的问题都是非常有效的到目前为止,多任务机器学习的大多数应用通常是在单一模态的情况下(如所有视觉任务或所有文本任务),尽管少数作者也考虑了多模态的设置。</p><p>一个特别有趣的研究方向将这三种趋势结合在一起,即在大规模ML加速器硬件上运行一个系统,目标是训练一个可以执行数千或数百万任务的单一模型。这样的模型可能由不同结构的许多不同组件组成,示例之间的数据流在逐个示例的基础上是相对动态的。该模型可能会使用像稀疏门控混合专家和学习路由这样的技术,以拥有一个非常大的容量模型,但在这个模型中,给定的任务或示例只稀疏地激活系统中总组件的一小部分(因此保持每个训练示例或推理的计算成本和功耗低得多)。一个有趣的探索方向是对不同的例子使用动态和自适应的计算量,这样“简单”的例子比“困难”的例子使用的计算量要少得多(这在今天的机器学习模型中是一个相对不寻常的特性)。图1描述了这样一个系统。</p><p><img src="/img/bVc2oG6" alt="" title=""></p><p>每个组件本身可能正在运行一些类似于automl的体系结构搜索,以便使组件的结构适应路由到该组件的数据类型如果有用,新任务可以利用在其他任务上训练的组件。希望通过非常大规模的多任务学习、共享组件和学习路由,模型可以非常快速地学习以较高的精度完成新任务,每个新任务的示例相对较少(因为模型能够利用其在完成其他相关任务时已经开发的专业知识和内部表示)。</p><p>在人工智能和计算机系统工程领域,构建一个能够处理数百万个任务,并能够学习自动成功完成新任务的单一机器学习系统是一个真正的巨大挑战。它将需要在许多领域的专业知识和进步,包括机器学习算法、公平和可解释性等负责任的AI主题、分布式系统和计算机架构,从而通过构建一个可以在机器学习的所有应用领域中独立解决新任务的系统来推动人工智能领域的发展。</p><h4>4.1负责任的AI开发</h4><p>虽然人工智能有能力在我们生活的许多方面帮助我们,但所有研究人员和实践者都应该确保这些方法是负责任的开发——仔细审查偏见、公平、隐私和其他社会考虑因素,这些工具可能会如何表现和影响他人,并努力适当地解决这些考虑因素。</p><p>制定一套明确的原则来指导负责任的发展也很重要。2018年,谷歌发布了一套人工智能原则,指导该公司在人工智能方面的工作和使用。人工智能原则列出了重要的考虑领域,包括机器学习系统中的偏见、安全、公平、问责、透明度和隐私等问题。近年来,其他组织和政府也遵循这一模式,发布了自己关于人工智能使用的原则。很高兴看到更多的组织发布他们自己的指南,我希望这一趋势将继续下去,直到它不再是一种趋势,而是所有机器学习研究和开发的标准。</p><h3>5.总结</h3><p>2010年代是深度学习研究和进步的黄金十年。在这十年里,该领域在1956年创造了人工智能领域的研讨会上提出的一些最困难的问题领域取得了巨大的进展。机器能够以早期研究人员所希望的方式看、听和理解语言。</p><p>这些核心领域的成功使许多科学领域取得了巨大的进步,使我们的智能手机变得更加智能,并使我们看到了未来的可能性,因为我们继续在创造更复杂和强大的深度学习模型,以帮助我们的日常生活。在强大无比的机器学习系统的帮助下,我们的未来将变得更有创造力、更有能力。我迫不及待地想看看未来会发生什么!</p><hr><p><strong>作者注:</strong><br>Alison Carroll, Heather Struntz 和 Phyllis Bendell 帮助编辑了这份手稿,并对如何呈现大部分材料提出了许多有用的建议。<br>©2022由Jeffrey Dean提供。在CC BY-NC 4.0许可下发布。</p>
有道词典Android客户端包体积优化之路
https://segmentfault.com/a/1190000041736312
2022-04-21T11:45:35+08:00
2022-04-21T11:45:35+08:00
有道AI情报局
https://segmentfault.com/u/youdaotec
1
<h2>1 背景</h2><p>有道词典从移动互联网之初就凭借小巧快速、功能强大的印象让用户爱上翻译查词,爱上学习。随着业务不断地迭代以及功能不断完善,有道词典不再是单纯的查词软件,而是变成了用户的综合学习平台。我们探索过社区、问答、直播、信息流等业务,目前也承载着音频、视频、课程、背单词、写作批改等等的功能。词典已经发展成为一个综合性的学习平台,小巧快速的初心仍然指引着我们不断进行启动速度以及包体积优化。</p><p>经过了不断的性能优化,目前我们的冷启动时间已经能维持在业界标准水平3s以内。我们近一个季度主要的性能优化工作集中在安装包体积优化上面。经过一系列的努力,我们包体积减少了23.7%,安装包体积从177MB减少到135MB,整体少了42MB。</p><p><img src="/img/bVcZhz7" alt="image.png" title="image.png"></p><p>以下详细介绍我们的分析以及实现细节。</p><h2>2 分析</h2><p>介绍下包体积包含的内容以及优化方法概述</p><blockquote><p>一般的APK安装包包含了以下一些目录和资源:</p><p>META-INF/ 签名文件</p><p>assets/ 程序使用的辅助资源文件</p><p>res/ 没有编译进入resources.arsc 资源文件,一般是图片</p><p>lib/ 依赖的不同native平台的库文件</p><p>resource.arsc 编译之后的文案、色值、大小、主题等资源索引</p><p>classes.dex 编译后的代码</p><p>AndroidMenifest.xml 应用的名称、版本、访问权限和引用的库文件信息</p></blockquote><p><img src="/img/bVcZhz8" alt="image.png" title="image.png"><br>可以看出占比较大的部分主要是分别是assets/、lib/、res/、classes.dex以及resources.arsc,大概对应的就是资源、库文件、代码以及资源索引。我们主要的优化思路如下(其中蓝色框部分为目前已经处理部分):</p><p><img src="/img/bVcZhAe" alt="image.png" title="image.png"></p><h2>3 技术实现细节</h2><h3>3.1 图片压缩</h3><p>在APK打包的过程中,aapt 工具会默认对图片进行无损压缩,不过默认的压缩并不能达到一个很好的压缩效果,经过了对比webp以及tinypng的压缩效果,我们最终选择了使用tinypng对图片进行压缩。并且我们编写了编译工具,对图片进行自动化压缩。</p><p>有损webp > tinypng > 无损webp</p><p><img src="/img/bVcZhAh" alt="image.png" title="image.png"></p><p>比如这张启动图,原大小724KB,压到75%左右的质量只有23.7KB。效果上有一点点差异,但可以接受。那么我们是否可以把全部png图压成有损webp呢?答案是否定的,可以看看下面的例子:</p><p><img src="/img/bVcZhAi" alt="image.png" title="image.png"></p><p>压缩前:</p><p><img src="/img/bVcZhAj" alt="image.png" title="image.png"></p><p>压缩后:</p><p><img src="/img/bVcZhAr" alt="image.png" title="image.png"></p><p>可以看到,相同的压缩质量下(75%),这个图就变得十分模糊,哪怕选择到了99%的压缩质量,渐变区域依然会出现一些没有自然过渡的条纹。</p><p><img src="/img/bVcZhAt" alt="image.png" title="image.png"><br>对于上述的情况,用tinypng方案更好</p><p>原图:643KB,</p><p>tinypng: 152KB,</p><p>webp:339KB</p><p>综上,对于有损webp,无法找到一个固定的压缩质量来适配所有场景。有损webp有些时候甚至比tinypng还大,但显示质量更差。</p><p>我们最初使用的抖音的McImage插件对图片进行处理,不过这个方案存在一些明显的问题:</p><ol><li>方案采用有损webp,有损webp无法定一个通用的压缩质量适应所有场景。</li><li>每次打包都要对所有图进行压缩,严重影响迭代效率。打包机要40分钟,且经常OOM。</li><li>没有对assets目录的图片进行处理。</li></ol><p><strong>针对以上问题,我们自己开发了一套使用tinypng的自动化图片压缩工具,做出以下调整:</strong></p><blockquote><p>1.对于大图png,用手工压成有损webp。收益大,且风险可控。<br>2.对于非大图,开发了一个image-optimization插件进行压缩。该插件方案为:</p><p>· png转tinypng。虽然是有损的,但从抽样来看,肉眼完全看不到明显变化。</p><p>· 对assets进行处理。assets内有前端png图,转tinypng不转webp的好处是不需要单独改html、js等文件,且对低版本系统兼容性更友好。flutter相关项目的flutter_assets图片比较大且没注意压缩。插件统一处理可以不需要打开flutter工程单独优化、重新打包。</p><p>· 对于已压缩的图片,做缓存处理,不需要重新压缩,打包的时候动态替换。压缩缓存跟随词典工程提交到gitlab统一管理。</p></blockquote><p>以下是我们图片自动化压缩插件处理的流程图:</p><p><img src="/img/bVcZhAI" alt="image.png" title="image.png"></p><p><em>这里压缩图是否可用判断,主要是大小判断,如果压出来比原图大,那么将舍弃。比如crunchPng压缩就存在这种情况</em></p><p>附加1:因为已经用了tinypng统一压缩,那么google官方自带的crunchPng建议关闭,否则打包速度变慢,而且优化好的图片也可能又变大,加入这行即可:</p><pre><code class="groovy">buildTypes.all {\
isCrunchPngs = false\
}</code></pre><p>附加2:无损webp和tinypng对比</p><p>如图所示,全量换tinypng比全量换webp(包含assets)少7.7MB。如果考虑到assets内的14.7MB其实是不能简单换webp的,差距会更大。<br><img src="/img/bVcZhAK" alt="image.png" title="image.png"></p><p>附加3:tinypng已经是最好的方案吗?</p><p>参考另一个ImageOptim工具,它结合OptiPNG, PNGCrush, AdvanceComp, PNGOUT, Jpegoptim + Jpegtran, 和 Gifsicle 等几个工具提供最好的优化效果,而且是几乎无损的。对于小部分图片ImageOptim压出来小,看起来没有差别。不过压缩速度非常慢。</p><p>所以,如果做到极致的话,可以进行多种压缩方案,选最佳的图作为替换。且我们的image-optimization插件从一开始设计的时候就预留了这种可扩展性。</p><p>附加4:AndResGuard优化对比</p><p>试了一下效果不明显,且出现部分资源丢失而崩溃的情况。效果不明显的原因,猜测是目前R8对资源名也有混淆压缩(以前proguard没有),所以AndResGuard现在的作用比较微弱。至于7zip的压缩没有开,理论上会导致启动速度变慢,觉得得不偿失(另外会导致Google Pay的Patch优化算法失效)。</p><h3>3.2 resources.arsc优化</h3><ul><li>语言包优化</li></ul><p><img src="/img/bVcZhAQ" alt="image.png" title="image.png"></p><p>打开resources.arsc的string,我们可以看到如下表格,会发现大量空的地方(如上图)。这些空白的地方,其实是用FF FF FF…字符进行占位的,占用了很多空间(如下图)。由于有道词典没有进行国际化翻译(有一个国际化版本叫U-Dictionary,欢迎支持),因此删掉不必要的语言版本有助于减少体积。</p><p><img src="/img/bVcZhAV" alt="image.png" title="image.png"></p><pre><code class="groovy">android {\
defaultConfig {\
resConfigs "zh"\
}\
}</code></pre><ul><li>如上所示,增加一行,保留中文即可。收获比想象中大,直接减少了3MB。</li><li>dimens优化查看了最近几个版本的arsc体积,发现有一个版本增加了5MB。</li><li>在这个版本我们做了平板适配功能,由于我们采用的是SmallestWith限定符适配方案(可以先了解下这个屏幕适配方案),因此产生大量的尺寸资源。</li></ul><p><img src="/img/bVcZhA3" alt="image.png" title="image.png"></p><p>一共是有3000多个资源,每一个资源有“values-sw300dp”到"values-sw1200dp"共90个版本,这块存在较大的优化空间。</p><p>sqb_px_xx”这一项是用于字体适配的,但词典用到最大的字体是“sqb_px_144”,所以优化了生成规则,减少了这一类资源。</p><p>优化后,资源数量由3012变成1662,减少了近一半。直接减少了2.5MB。</p><h3>3.3 业务代码删除</h3><p>由于Proguard以及lint等工具是从代码引用的角度进行分析和代码裁剪,如果一些废弃的代码不先进行删除会影响后续工作的效果。对于一些已经废弃没有入口的业务,不进行处理的话那么代码、资源会只增不减。业务删减应该是所有包体积流程的第一步,否则后面的去掉无用资源、图片压缩、混淆等等效果都要打一个折扣。如果时间有限的话,那么删最近的需求会比删远古时代的需求收益会大点,原因是越靠近现在的项目,图片资源、字体资源,以及用到so库都会比较大(尤其是音视频)。</p><p>这部分工作主要是对业务功能的整理以及沟通部分陈旧业务是否可以进行删除,除此之外就是需要细致的引用分析将废弃业务相关代码剥离出来进行删除。</p><p>一个良好的项目架构对于日后业务代码的剥离有很大好处。目前新开发的功能我们采用的是分层分模块的组织架构,功能模块之间不存在相互依赖,因此以后对于业务的抽离或者删除会更加方便。</p><p><img src="/img/bVcZhA4" alt="image.png" title="image.png"></p><h3>3.4 无用资源删除</h3><p>对于无用资源删除我们主要使用了两个方法,一个是通过 lint 工具找到应用中可能没有使用的资源并逐一进行判断确认没有使用后进行删除,第二个是在build.gradle文件中加入shrinkResources在编译阶段使用R8工具进行删除</p><pre><code class="groovy">buildTypes {\
release {\
// Zipalign优化\
zipAlignEnabled true\
// 移除无用的resource文件\
shrinkResources true\
// 移除没用的代码\
minifyEnabled true\
}\
}</code></pre><p>使用 lint 工具需要注意对以下一些场景进行再次判断确认</p><ol><li>对于反射性引用资源,可能会被识别成无用资源,比如push用到的通知栏icon</li><li>DataBinding用到的layout资源会被识别成无用资源</li></ol><h3>3.5 压缩混淆</h3><blockquote><p>使用R8工具在编译阶段对代码进行压缩混淆,从而达到压缩安装包体积的效果。主要分为以下4个步骤:</p><ol><li>压缩(shrink) 移除未使用的类、方法、字段等;</li><li>优化(optimize) 优化字节码、简化代码等操作;</li><li>混淆(obfuscate) 使用简短的、无意义的名称重命名类名、方法名、字段等;</li><li>预校验(preverify) 为class添加预校验信息。</li></ol></blockquote><p>我们在两年前就引入了Proguard,不过考虑到混淆带来的问题使用了-dontobfuscate配置取消混淆。我们发现之前的规则中从依赖库中继承了 -dontoptimize 的配置导致优化也没有生效。这次优化中,我们全面解决了混淆带来的众多问题,全面开启了优化以及混淆。</p><p>由于我们之前已经开启过了压缩,因此需要使用到的类已经在proguard中进行了保留。开启混淆后还需要处理以下一些问题:</p><ul><li>getIdentifier 通过名称获取资源问题。如果是普通模式,则会自动不去掉相关资源:</li></ul><p><img src="/img/bVcZhA7" alt="image.png" title="image.png"></p><ul><li>检查Resources.getValue 相关逻辑</li><li>检查AssetManager.open相关逻辑</li><li>反射,全局搜一下反射包,修改相关位置 java.lang.reflect</li><li>处理Retrofit报错问题(<a href="https://link.segmentfault.com/?enc=AFraNSUcv5THJzDmiVqM%2Bg%3D%3D.BwvjR%2B3YKecVAYy96S5FXZIv8OJSZM%2FOBdTI4DE9p%2BoCVTuCjiAzhYam%2FTEo2MPU" rel="nofollow">https://github.com/square/ret...</a>),目前使用升级Gradle插件版本进行解决</li></ul><pre><code class="groovy">Caused by: java.lang.IllegalArgumentException: Method return type must not include a type variable or wildcard: ho8<su3<?>>\
for method CheckInApi.popupConfig\
at retrofit2.Utils.methodError(SourceFile:5)\
at retrofit2.Utils.methodError(SourceFile:1)\
at retrofit2.ServiceMethod.parseAnnotations(SourceFile:7)\
at retrofit2.Retrofit.loadServiceMethod(SourceFile:4)\
at retrofit2.Retrofit$1.invoke(SourceFile:6)\
at java.lang.reflect.Proxy.invoke(Proxy.java:1006)\
at $Proxy23.popupConfig(Unknown Source)\
at com.youdao.dict.checkin.CheckInPopupManager.requestPopupConfig(SourceFile:3)\
at java.lang.reflect.Method.invoke(Native Method)</code></pre><p>Proguard的规则会很大程度上影响R8对代码压缩和混淆带来的效果,因此对压缩规则的回顾以及整理可以帮助进一步的体积压缩。</p><h3>3.6 字体优化</h3><p>字体优化这部分是在之前的版本已经实现过的,取得的效果也挺明显,这里补充说明一下。</p><blockquote><p><strong>- 字体裁剪</strong></p><p>一般的字体库大小会有十几二十兆。但实际上用到的字符只有很少一部分,因此针对实际的使用场景对字体库进行适当的裁剪,收益非常大。</p><p>常用字列表:<a href="https://link.segmentfault.com/?enc=RYilNjYaBakrvaSAvE57Xg%3D%3D.kK5r%2Bx3YaDyEmivwm51NNRlcCJ4zRw7oCYK5Z%2B4mcvviLno%2BjiSQiozW5E%2BxQ%2FmPYLrz6JGXuRVfITj%2BEyOMdA%3D%3D" rel="nofollow">https://github.com/DavidSheh/...</a></p><p>字体压缩工具:<a href="https://link.segmentfault.com/?enc=ubYrD%2FuX8%2BaWj6SEX%2FkpxQ%3D%3D.YNQfZj3NXHCosTSauhL0Ts%2Fga23efwmGrt9nS0qavt%2F98L3dUtsynnU2%2B5kgp6Vl" rel="nofollow">https://github.com/forJrking/...</a></p><p><strong>- 字体合并</strong></p><p>一般来说,我们开发都会模块化,不同的团队采用在开发不同功能的时候,有可能用到相同的字体。如果稍不注意就会复制成两份、三份,文件大大增加。词典这边的方案是把共有的字体下沉到底层core基础库,供各个模块引用。</p></blockquote><h2>4 展望</h2><p>经过了上述的工作,目前词典的安装包体积优化了23.7%,整体减少了42MB。在接下来的Q2,我们将准备做两方面的事情。</p><h3>4.1 包体积监控</h3><p>在包体积优化的过程中,我们在含辛茹苦地砍掉一点体积之后,转过头来发现别的同学又随随便便扔进去几MB的大图。因此,如何坚守胜利的果实,让包体积保持最佳状态成了重中之重。</p><p>打包任务增加了是否检查包大小限制(默认都要检查) 的选项;merge request之后,词典的打包任务会触发自动构建;</p><blockquote><p>打包任务完成之后,如果需要检查包大小,那就开始触发apkcheck步骤;具体如下:</p><ol><li>打包任务完成之后增加脚本操作,把本次构建的数据(如apk文件地址,mapping文件地址,R文本地址等)写入临时文件;</li><li>打包任务构建后操作增加 Trigger parameterized build on other projects,触发apk 大小检查任务;</li><li>开始检查流程,检查流程根据参数对apk进行检查任务,并且把任务结果生成html;</li></ol></blockquote><h2>4.2 动态分发</h2><p><strong>- 整体业务分发</strong></p><p>可以使用插件化以及动态加载等技术,不过这些可能不是最难的,最难的是如何把一些祖传的、低频的、而又相互依赖的代码抽离出来,形成独立模块去做分发、动态加载。</p><p><strong>- 业务子功能分发(预计可优化39.6MB)</strong></p><blockquote><ol><li>数据库(单词锁屏8MB)</li></ol><p>单词锁屏可以保留几百kb数据在本地让用户备用,同时再下载完整的词库。</p><ol start="2"><li>OCR引擎数据(22.5MB)</li></ol><p>用户应该可以按需下载训练模型,而不是直接内置;当没有训练模型的时候,可以直接网络请求。</p><ol start="3"><li>字体(9.1MB)</li></ol><p>除了查词等高频业务,低频业务的字体可以动态分发,有则显示,无则使用系统的即可。但emoji的兼容库比较特殊,主要用在首页信息流的帖子、UGC发帖等。如果没有兼容库,用户在遇到特别的emoji中可能会显示“豆腐块”,这个时候如果emoji字体库还没下载完成,需要进行替换兜底处理。另外哪怕用户系统已经有这个emoji内置字体,也有可能显示效果各个手机不太一样,需要跟UI确认一下是要替换掉,还是暂时这样显示。</p></blockquote><p>保持住词典小巧快速、功能强大的初心是我们不停进行性能优化的动力,在接下来的工作中,我们会对启动速度、安装包体积以及内存占用等多方面进行持续优化和改进,欢迎大家继续关注和支持!</p><h2>5 参考</h2><ul><li>Reduce your app size</li><li>Shrink, obfuscate, and optimize your app</li><li>抖音图片压缩插件McImage</li><li>腾讯包体积监控ApkChecker</li></ul>
从 Redux 源码谈谈函数式编程
https://segmentfault.com/a/1190000041698962
2022-04-13T18:37:59+08:00
2022-04-13T18:37:59+08:00
有道AI情报局
https://segmentfault.com/u/youdaotec
3
<blockquote><p>摘要</p><p>在 React 的世界中,状态管理方案不下几百种,但其中最经典的,莫过于 Redux 。如果你想学习函数式编程,那么 Redux<br>源码就是最好的学习材料。考虑到不少小伙伴用的是 Vue ,本人争取让这篇文章写得通俗易懂,让没有接触过 React 技术栈的同学也能掌握<br>Redux 。</p></blockquote><p>Redux 属于典型的“百行代码,千行文档”,其中核心代码非常少,但是思想不简单,可以总结为下面两点:</p><ul><li><p>全局状态唯一且不可变(Immutable) ,不可变的意思是当需要修改状态的时候,用一个新的来替换,而不是直接在原数据上做更改:</p><pre><code class="js">let store = { foo: 1, bar: 2 };
// 当需要更新某个状态的时候
// 创建一个新的对象,然后把原来的替换掉
store = { ...store, foo: 111 };</code></pre></li></ul><p><strong>这点与 Vue 恰好相反,在 Vue 中必须直接在原对象上修改,才能被响应式机制监听到,从而触发 setter 通知依赖更新。</strong></p><blockquote><p>状态更新通过一个纯函数(Reducer)完成。纯函数(Pure function)的特点是:</p><ul><li>输出仅与输入有关;</li><li>引用透明,不依赖外部变量;</li><li>不产生副作用;</li></ul></blockquote><p>因此对于一个纯函数,相同的输入一定会产生相同的输出,非常稳定。使用纯函数进行全局状态的修改,使得全局状态可以被预测。</p><h2>1. 需要了解的几个概念</h2><p>在使用 Redux 及阅读源码之前需要了解下面几个概念:</p><h3>Action</h3><p>action 是一个普通 JavaScript 对象,用来描述如何修改状态,其中需要包含 type 属性。一个典型的 action 如下所示:</p><pre><code class="js">const addTodoAction = {
type: 'todos/todoAdded',
payload: 'Buy milk'
}</code></pre><h3>Reducers</h3><p>reducer 是一个纯函数,其函数签名如下:</p><pre><code class="js">/**
* @param {State} state 当前状态
* @param {Action} action 描述如何更新状态
* @returns 更新后的状态
*/
function reducer(state: State, action: Action): State</code></pre><p>reducer 函数的名字来源于数组的 reduce 方法,因为它们类似数组 reduce 方法传递的回调函数,也就是上一个返回的值会作为下一次调用的参数传入。</p><blockquote><p>reducer函数的编写需要严格遵顼以下规则:</p><ul><li><p>检查reducer是否关心当前的action</p><ul><li>如果是,就创建一份状态的副本,使用新的值更新副本中的状态,然后返回这个副本</li></ul></li><li>否则就返回当前状态</li></ul></blockquote><p>一个典型的 reducer 函数如下:</p><pre><code class="js">const initialState = { value: 0 }
function counterReducer(state = initialState, action) {
if (action.type === 'counter/incremented') {
return {
...state,
value: state.value + 1
}
}
return state
}</code></pre><h3>Store</h3><p>通过调用 createStore 创建的 Redux 应用实例,可以通过 getState() 方法获取到当前状态。</p><h3>Dispatch</h3><p>store 实例暴露的方法。更新状态的唯一方法就是通过 dispatch 提交 action 。store 将会调用 reducer 执行状态更新,然后可以通过 getState() 方法获取更新后的状态:</p><pre><code class="js">store.dispatch({ type: 'counter/incremented' })
console.log(store.getState())
// {value: 1}</code></pre><h3>storeEnhancer</h3><p>createStore 的高阶函数封装,用于增强 store 的能力。Redux 的 applyMiddleware 是官方提供的一个 enhancer 。</p><h3>middleware</h3><p>dispatch 的高阶函数封装,由 applyMiddleware 把原 dispatch替换为包含 middleware 链式调用的实现。Redux-thunk 是官方提供的 middleware,用于支持异步 action 。</p><h2>2. 基本使用</h2><p>学习源码之前,我们先来看下 Redux 的基本使用,便于更好地理解源码。</p><p>首先我们编写一个 Reducer 函数如下:</p><pre><code class="js">// reducer.js
const initState = {
userInfo: null,
isLoading: false
};
export default function reducer(state = initState, action) {
switch (action.type) {
case 'FETCH_USER_SUCCEEDED':
return {
...state,
userInfo: action.payload,
isLoading: false
};
case 'FETCH_USER_INFO':
return { ...state, isLoading: true };
default:
return state;
}
}</code></pre><blockquote><p>在上面代码中:</p><ul><li>reducer首次调用的时候会传入initState作为初始状态,然后switch...case最后的default用来获取初始状态</li><li>在switch...case中还定义了两个action.type用来指定如何更新状态</li></ul></blockquote><p>接下来我们创建 store :</p><pre><code class="js">// index.js
import { createStore } from "redux";
import reducer from "./reducer";
const store = createStore(reducer);</code></pre><p>store 实例会暴露两个方法 getState 和 dispatch ,其中 getState 用于获取状态,dispatch 用于提交 action 修改状态,同时还有一个 subscribe 用于订阅store的变化:</p><pre><code class="js">// index.js
// 每次更新状态后订阅 store 变化
store.subscribe(() => console.log(store.getState()));
// 获取初始状态
store.getState();
// 提交 action 更新状态
store.dispatch({ type: "FETCH_USER_INFO" });
store.dispatch({ type: "FETCH_USER_SUCCEEDED", payload: "测试内容" });</code></pre><p>我们运行一下上面的代码,控制台会先后打印:</p><pre><code class="js">{ userInfo: null, isLoading: false } // 初始状态
{ userInfo: null, isLoading: true } // 第一次更新
{ userInfo: "测试内容", isLoading: false } // 第二次更新</code></pre><h2>3. Redux Core 源码分析</h2><p>上面的例子虽然很简单,但是已经包含 Redux 的核心功能了。接下来我们来看下源码是如何实现的。</p><h3>createStore</h3><p>可以说 Redux 设计的所有核心思想都在 createStore 里面了。 createStore 的实现其实非常简单,整体就是一个闭包环境,里面缓存了 currentReducer 和 currentState ,并且定义了getState、subscribe、dispatch 等方法。</p><p>createStore 的核心源码如下,由于这边还没用到 storeEnhancer ,开头有些if...else的逻辑被省略了,顺便把源码中的类型注解也都去掉了,方便阅读:</p><pre><code class="javascript">// src/createStore.ts
function createStore(reducer, preloadState = undefined) {
let currentReducer = reducer;
let currentState = preloadState;
let listeners = [];
const getState = () => {
return currentState;
}
const subscribe = (listener) => {
listeners.push(listener);
}
const dispatch = (action) => {
currentState = currentReducer(currentState, action);
for (let i = 0; i < listeners.length; i++) {
const listener = listeners[i];
listener();
}
return action;
}
dispatch({ type: "INIT" });
return {
getState,
subscribe,
dispatch
}
}</code></pre><blockquote><p>createStore 的调用链路如下:</p><ul><li>首先调用 createStore 方法,传入 reducer 和 preloadState 。preloadState 代表初始状态,假如不传那么 reducer 必须要指定初始值;</li><li>将 reducer 和 preloadState 分别赋值给 currentReducer 和 currentState 用于创建闭包;</li><li>创建 listeners 数组,这其实就是基于发布订阅模式,listeners 就是发布订阅模式的事件中心,也是通过闭包缓存;</li><li>创建 getState 、subscribe 、dispatch 等函数;</li><li>调用 dispatch 函数,提交一个 INIT 的 action 用来生成初始state,在 Redux 源码中,这里的 type 是一个随机数;</li><li>最后返回一个包含 getState 、subscribe 、dispatch 函数的对象,即 store 实例;</li></ul></blockquote><p>那么很显然,外界无法访问到闭包的值,只能通过getState函数访问。</p><p>为了订阅状态更新,可以使用 subscribe 函数向事件中心 push 监听函数(注意 listener 是允许副作用存在的)。</p><p>当需要更新状态时,调用 dispatch 提交 action 。在 dispatch 函数中调用 currentReducer(也就是 reducer 函数),并传入 currentState 和 action ,然后生成一个新的状态,传给 currentState 。在状态更新完成后,将订阅的监听函数执行一遍(实际上只要调用 dispatch ,即使没有对 state 做任何修改,也会触发监听函数)。</p><p>如果有熟悉面向对象编程的小伙伴可能会说,createStore里面做的事情可以封装到一个类里面。确实可以,本人用 TypeScript 实现如下(发布订阅的功能不写了):</p><pre><code class="javascript">type State = Object;
type Action = {
type: string;
payload?: Object;
}
type Reducer = (state: State, action: Action) => State;
// 定义 IRedux 接口
interface IRedux {
getState(): State;
dispatch(action: Action): Action;
}
// 实现 IRedux 接口
class Redux implements IRedux {
// 成员变量设为私有
// 相当于闭包作用
private currentReducer: Reducer;
private currentState?: State;
constructor(reducer: Reducer, preloadState?: State) {
this.currentReducer = reducer;
this.currentState = preloadState;
this.dispatch({ type: "INIT" });
}
public getState(): State {
return this.currentState;
}
public dispatch(action: Action): Action {
this.currentState = this.currentReducer(
this.currentState,
action
);
return action;
}
}
// 通过工厂模式创建实例
function createStore(reducer: Reducer, preloadState?: State) {
return new Redux(reducer, preloadState);
}</code></pre><p><strong>你看,多有意思,函数式编程和面向对象编程竟然殊途同归了。</strong></p><h3>applyMiddleware</h3><p>applyMiddleware 是 Redux 中的一个难点,虽然代码不多,但是里面用到了大量函数式编程技巧,本人也是经过大量源码调试才彻底搞懂。</p><p>首先要能看懂这种写法:</p><pre><code class="js">const middleware =
(store) =>
(next) =>
(action) => {
// ...
}</code></pre><p>上面的写法相当于:</p><pre><code class="js">const middleware = function(store) {
return function(next) {
return function(action) {
// ...
}
}
}</code></pre><p>其次需要知道,这种其实就是函数柯里化,也就是可以分步接受参数。如果内层函数存在变量引用,那么每次调用都会生成闭包。</p><p>说到闭包,有些同学马上就想到内存泄漏。但实际上闭包在平时项目开发中非常常见,很多时候我们不经意间就创建了闭包,但往往都被我们忽略了。</p><p>闭包一大作用就是缓存值,这和声明一个变量在赋值的效果是类似的。而闭包的难点就在于,变量是显式声明,而闭包往往是隐式的,什么时候创建闭包,什么时候更新了闭包的值,很容易被忽略。</p><p>可以这么说,函数式编程就是围绕闭包展开的。在下面的源码分析中,会看到大量闭包的例子。</p><p>applyMiddleware 是 Redux 官方实现的 storeEnhancer ,实现了一套插件机制,增加 store 的能力,例如实现异步 Action ,实现 logger 日志打印,实现状态持久化等等。</p><pre><code class="js">export default function applyMiddleware<Ext, S = any>(
...middlewares: Middleware<any, S, any>[]
): StoreEnhancer<{ dispatch: Ext }></code></pre><p><strong>个人观点,这样做的好处就是提供了造轮子的空间</strong></p><p>applyMiddleware 接受一个或多个 middleware 实例,然后再传给createStore:</p><pre><code class="js">import { applyMiddleware, createStore } from "redux";
import thunk from "redux-thunk"; // 使用 thunk 中间件
import reducer from "./reducer";
const store = createStore(reducer, applyMiddleware(thunk));</code></pre><p>createStore 入参中只接受一个 storeEnhancer ,如果需要传入多个,可以使用 Redux Utils 中的 compose 函数将它们组合起来。</p><p><strong>compose 函数在后面会介绍</strong></p><p>看上面的用法,可以猜测 applyMiddleware 肯定也是个高阶函数。之前说到 createStore 前面有些if..else逻辑因为没用到 storeEnhancer 所以被省略了。这边我们一起来看下。</p><p>首先看 createStore 的函数签名,实际上是可以接受 1-3 个参数。其中 reducer 是必须要传递的。当第二个参数为函数类型,会识别为 storeEnhancer。如果第二个参数不是函数类型,则会识别为 preloadedState ,此时还可以再传递一个函数类型的 storeEnhancer :</p><pre><code class="javascript">function createStore(reducer: Reducer, preloadedState?: PreloadedState | StoreEnhancer, enhancer?: StoreEnhancer): Store</code></pre><p>可以看到源码中参数校验的逻辑:</p><pre><code class="js">// src/createStore.ts:71
if (
(typeof preloadedState === 'function' && typeof enhancer === 'function') ||
(typeof enhancer === 'function' && typeof arguments[3] === 'function')
) {
// 传递两个函数类型参数的时候,抛出异常
// 也就是只接受一个 storeEnhancer
throw new Error();
}</code></pre><p>当第二个参数为函数类型,将它作为 storeEhancer 处理:</p><pre><code class="javascript">// src/createStore.ts:82
if (typeof preloadedState === 'function' && typeof enhancer === 'undefined') {
enhancer = preloadedState as StoreEnhancer<Ext, StateExt>
preloadedState = undefined
}</code></pre><p>接下来是一个比较难的逻辑:</p><pre><code class="javascript">// src/createStore.ts:87
if (typeof enhancer !== 'undefined') {
// 如果使用了 enhancer
if (typeof enhancer !== 'function') {
// 如果 enhancer 不是函数就抛出异常
throw new Error();
}
// 直接返回调用 enhancer 之后的结果,并没有往下继续创建 store
// enhancer 肯定是一个高阶函数
// 先传入了 createStore,又传入 reducer 和 preloadedState
// 说明很有可能在 enhancer 内部再次调用 createStore
return enhancer(createStore)(
reducer,
preloadedState
)
}</code></pre><p>下面我们来看一下 applyMiddleware 的源码,为便于阅读,把源码中的类型注解都去掉了:</p><pre><code class="js">// src/applyMiddleware.ts
import compose from './compose';
function applyMiddleware(...middlewares) {
return (createStore) => (reducer, preloadedState) => {
const store = createStore(reducer, preloadedState);
let dispatch = () => {
throw new Error();
}
const middlewareAPI = {
getState: store.getState,
dispatch: (action, ...args) => dispatch(action, ...args)
}
const chain = middlewares.map(middleware => middleware(middlewareAPI));
dispatch = compose(...chain)(store.dispatch);
return {
...store,
dispatch
}
}
}</code></pre><p>可以看到这里代码并不多,但是出现了一个函数嵌套函数的情形:</p><pre><code class="js">const applyMiddleware = (...middlewares) =>
(createStore) =>
(reducer, preloadedState) => {
// ...
}</code></pre><blockquote><p>分析一下源码中的调用链路:</p><ul><li>调用 applyMiddleware 时,传入中间件实例,返回 enhancer 。从剩余参数的用法看出,支持传入多个 middleware ;</li><li>由createStore调用 enhancer ,分两次传入 createStore 和 reducer 、preloadedState ;</li><li>内部再次调用 createStore ,这次由于没有传 enhancer ,所以直接走创建 store 的流程;</li><li>创建一个经过修饰的 dispatch 方法,覆盖默认 dispatch ;</li><li>构造 middlewareAPI ,对 middleware 注入 middlewareAPI ;</li><li>将 middleware 实例组合为一个函数,再向 middleware 传递默认的 store.dispatch 方法;</li><li>最后返回一个新的 store 实例,此时 store 的 dispatch 方法经过了 middleware 修饰;</li></ul></blockquote><p>这里涉及到 compose 函数,是函数式编程范式中经常用到的一种处理,创建一个从右到左的数据流,右边函数执行的结果作为参数传入左边,最终返回一个以上述数据流执行的函数:</p><pre><code class="js">// src/compose.ts:46
export default function compose(...funcs) {
if (funcs.length === 0) {
return (arg) => arg
}
if (funcs.length === 1) {
return funcs[0]
}
return funcs.reduce(
(a, b) =>
(...args) =>
a(b(...args))
)
}</code></pre><blockquote>思考题:如果希望把执行顺序改为从左往右,需要怎么改?</blockquote><p>通过这边的代码,我们不难推断出一个中间件的结构:</p><pre><code class="js">function middleware({ dispatch, getState }) {
// 接收 middlewareAPI
return function(next) {
// 接收默认的 store.dispatch 方法
return function(action) {
// 接收组件调用 dispatch 传入的 action
console.info('dispatching', action);
let result = next(action);
console.log('next state', store.getState());
return result;
}
}
}</code></pre><blockquote><p>看到这里,我想大多数读者都会有两个问题:</p><ol><li>通过 middlewareAPI 获取的 dispatch 函数和 store 实例最终暴露的 dispatch 函数都是经过修饰的吗;</li><li>为了防止在创建 middleware 的时候调用 dispatch ,applyMiddleware 给新的 dispatch 初始化为一个空函数,且调用会抛出异常,那么这个函数究竟在何时被替换掉的;</li></ol></blockquote><p>大家可以先试着思考一下。</p><p>说实话,本人在阅读源码的时候也被这两个问题困扰,大多数技术文章也都没有给出解释。没办法,只能通过调试源码来找答案。经过不断调试,终于搞清楚了,middlewareAPI 的 dispatch 函数本身其实就是以闭包形式引入的,这个闭包可能没多少人能看得出来:</p><pre><code class="js">// 定义新的 dispatch 方法
// 此时是一个空函数,调用会抛出异常
let dispatch = () => {
throw new Error();
}
// 定义 middlewareAPI
// 注意这里的 dispatch 是通过闭包形式引入的
const middlewareAPI = {
getState: store.getState,
dispatch: (action, ...args) => dispatch(action, ...args)
}
// 对 middleware 注入 middlewareAPI
// 此时在 middleware 中调用 dispatch 会抛出异常
const chain = middlewares.map(middleware => middleware(middlewareAPI));</code></pre><p>然后下面这段代码其实做了两件事,一方面将 middleware 组合为一个函数,注入默认 dispatch 函数,另一方面将新的 dispatch 初始的空函数替换为正常可执行的函数。同时由于 middlewareAPI 的 dispatch 是以闭包形式引入的,当 dispatch 更新之后,闭包中的值也相应更新:</p><pre><code class="js">// 将 dispatch 替换为正常的 dispatch 方法
// 注意闭包中的值也会相应更新,middleware 可以访问到更新后的方法
dispatch = compose(...chain)(store.dispatch);</code></pre><p>也就是说,createStore 生成的实例暴露的 dispatch 和 middleware 获取的都是修饰后的 dispatch ,并且应该是长这样:</p><pre><code class="js">function(action) {
// 注意这里存在闭包
// 可以获取到中间件初始化传入的 dispatch、getState 和 next
// 如果你打断点,可以在 scope 中看到闭包的变量
// 同时注意这里的 dispatch 就是这个函数本身
console.info('dispatching', action);
let result = next(action);
console.log('next state', store.getState());
return result;
}</code></pre><h2>4. 处理异步 Action</h2><p>由于 reducer 需要严格控制为纯函数,因此不能在里面进行异步操作,也不能进行网络请求。有些同学可能会说,虽然 reducer 里面不能放异步代码,但是可以把 dispatch 函数放在异步回调中调用呀:</p><pre><code class="js">setTimeout(() => {
this.props.dispatch({ type: 'HIDE_NOTIFICATION' })
}, 5000)</code></pre><p><strong>在 React 组件中通常用 connect 把 dispatch 映射到组件的props 中,类似 Vuex 中的 mapAction 用法。</strong></p><blockquote><p>确实可以!Redux 作者 Dan Abramov 在 Stackoverflow 上面有一个非常好的回答,其中就赞同了这种用法:</p><p><a href="https://link.segmentfault.com/?enc=uFqce%2BNBIocMi86pr%2FY5Lw%3D%3D.Yru8leDy0QfTcPTia1htRx6QIO2Gt87NsUXrmUV2q%2FXhMl1eTE0nOUbSlAPGzTf55CzqQnZ%2BJULME4%2BXZL8FndhqOBvG5%2F4It0PDKHGQyK1y5uJpTx50DaZfYnWq7G1rOCdL53cst8w3D7f2QqDM7g%3D%3D" rel="nofollow">https://stackoverflow.com/questions/35411423/how-to-dispatch-a-redux-action-with-a-timeout/35415559#35415559</a></p></blockquote><p>本人将 Dan Abramov 的核心观点总结如下。</p><ul><li>Redux 确实提供了一些处理异步 Action 的替代方法,但应该只在当你意识到你编写了大量模板代码的时候再去使用。否则就用最简单的方案(如无必要,勿增实体);</li><li>当多个组件需要用到同一个 action.type 时,为避免 action.type 拼写错误,需要抽离公共的 actionCreator,例如:</li></ul><pre><code class="js"> // actionCreator.js
export function showNotification(text) {
return { type: 'SHOW_NOTIFICATION', text }
}
export function hideNotification() {
return { type: 'HIDE_NOTIFICATION' }
}
// component.js
import { showNotification, hideNotification } from '../actionCreator'
this.props.dispatch(showNotification('You just logged in.'))
setTimeout(() => {
this.props.dispatch(hideNotification())
}, 5000)</code></pre><ul><li><p>上面的逻辑在简单场景下完全可行,但是随着业务复杂度增加会出现几个问题:</p><ol><li>通常状态更新有好几个步骤,而且存在逻辑上的先后顺序,例如通知的展示和隐藏,导致模板代码较多;</li><li>提交的 action 没有状态,如出现竞争条件可能导致状态更新出 bug ;</li></ol></li><li>出于上面的问题,需要抽离异步的 actionCreator ,把涉及状态更新的操作封装进去,便于复用,同时为每次 dispatch 生成唯一 id :</li></ul><pre><code class="js"> // actions.js
function showNotification(id, text) {
return { type: 'SHOW_NOTIFICATION', id, text }
}
function hideNotification(id) {
return { type: 'HIDE_NOTIFICATION', id }
}
let nextNotificationId = 0
export function showNotificationWithTimeout(dispatch, text) {
const id = nextNotificationId++
dispatch(showNotification(id, text))
setTimeout(() => {
dispatch(hideNotification(id))
}, 5000)
}</code></pre><p>然后在页面组件中这样使用,解决了模板代码和状态更新冲突问题:</p><pre><code class="js"> // component.js
showNotificationWithTimeout(this.props.dispatch, 'You just logged in.')
// otherComponent.js
showNotificationWithTimeout(this.props.dispatch, 'You just logged out.')</code></pre><ul><li>细心的同学应该注意到,这边传递了 dispatch 。这是因为,正常来说只有组件中能访问到 dispatch ,为了能让外部封装的函数也能访问,我们需要将 dispatch 作为参数传过去;</li><li>这时有些同学会提出质疑,如果将 store 作为全局单例,不就可以直接访问了:</li></ul><pre><code class="js"> // store.js
export default createStore(reducer)
// actions.js
import store from './store'
// ...
let nextNotificationId = 0
export function showNotificationWithTimeout(text) {
const id = nextNotificationId++
store.dispatch(showNotification(id, text))
setTimeout(() => {
store.dispatch(hideNotification(id))
}, 5000)
}
// component.js
showNotificationWithTimeout('You just logged in.')
// otherComponent.js
showNotificationWithTimeout('You just logged out.')</code></pre><ul><li>上面这样从操作上来说确实可行,但是 Redux 团队并不赞同单例的写法。他们的理由是,如果 store 变为单例,会导致服务端渲染的实现变得困难,同时测试也不方便,如要改用 mock store 需要修改所有 import ;</li><li>基于上面的原因,Redux 团队还是建议使用函数参数将 dispatch 传递出去,尽管这样很麻烦。那么有没有一种解决方案呢?有的,使用 Redux-thunk 就解决了这个问题;</li><li>实际上,Redux-thunk 的作用是教 Redux 识别函数类型的特殊 Action ;</li><li>中间件启用后,当 dispatch 的 Action 为函数类型,Redux-thunk 就会给这个函数传入 dispatch 作为参数,需要注意最终 reducer 拿到的仍然是普通 JavaScript 对象作为 Action :</li></ul><pre><code class="js"> // actions.js
function showNotification(id, text) {
return { type: 'SHOW_NOTIFICATION', id, text }
}
function hideNotification(id) {
return { type: 'HIDE_NOTIFICATION', id }
}
let nextNotificationId = 0
export function showNotificationWithTimeout(text) {
return function (dispatch) {
const id = nextNotificationId++
dispatch(showNotification(id, text))
setTimeout(() => {
dispatch(hideNotification(id))
}, 5000)
}
}</code></pre><p>在组件中使用如下:</p><pre><code class="js"> // component.js
this.props.dispatch(showNotificationWithTimeout('You just logged in.'))</code></pre><p>好了,Dan Abramov 的观点就总结到这里。</p><p>看到这里大家应该清楚 Redux-thunk 的作用了,Redux-thunk 本身并没有提供异步解决方案,实现异步就是使用最简单的方法,把 dispatch 函数放在异步回调中。很多时候我们会封装异步的 actionCreator ,在异步操作中每次都需要把 dispatch 传递出来很麻烦,Redux-thunk 对 dispatch 函数进行高阶封装,允许接受函数类型的 Action ,同时给这个 Action 传入 dispatch 和 getState 作为参数,这样就不用每次手动传递。</p><p>在看源码之前,大家可以结合 applyMiddleware 源码,想一下 Redux-thunk 内部实现。</p><p>其实 Redux-thunk 实现原理非常简单,代码如下:</p><pre><code class="js">// src/index.ts:15
function createThunkMiddleware(extraArgument) {
return ({ dispatch, getState }) =>
next =>
action => {
if (typeof action === 'function') {
return action(dispatch, getState, extraArgument)
}
return next(action)
}
}</code></pre><p>在 Redux-thunk 内部,首先会调用 createThunkMiddleware 方法得到一个高阶函数然后向外导出。这个函数就是我们之前分析的中间件结构:</p><pre><code class="js">({ dispatch, getState }) =>
next =>
action => {
if (typeof action === 'function') {
return action(dispatch, getState, extraArgument)
}
return next(action)
}</code></pre><p>首先在初始化阶段,applyMiddleware 会为 thunk 先后注入 middlewareAPI (对应 dispatch 和 getState 形参)和 store.dispatch (即原本的 dispatch 方法,对应 next 形参)。</p><p>在初始化完成之后,store 实例的 dispatch 会被替换为一个经过修饰的 dispatch 方法(middlewareAPI 中的 dispatch 由于是闭包引用,也会被替换),用 dispatch.toString() 打印可以输出如下内容:</p><pre><code class="js">// 注意这里可以访问到闭包中的 dispatch、getState 和 next
// 初始化完成后的 dispatch 实际上就是下面这个函数本身
action => {
if (typeof action === 'function') {
return action(dispatch, getState, extraArgument)
}
return next(action)
}</code></pre><p>接下来的事情就很简单了,当我们提交一个函数类型的 Action :</p><pre><code class="js">// actions.js
const setUserInfo = data => ({
type: "SET_USER_INFO",
payload: data
})
export const getUserInfoAction = userId => {
return dispatch => {
getUserInfo(userId)
.then(res => {
dispatch(setUserInfo(res));
})
}
}
// component.js
import { getUserInfoAction } from "./actionCreator";
this.props.dispatch(getUserInfoAction("666"));</code></pre><p>当提交的 action 为函数类型的时候,就调用这个函数,然后传入 dispatch 、getState 、extraArgument 参数:</p><pre><code class="js">if (typeof action === 'function') {
return action(dispatch, getState, extraArgument)
}</code></pre><p>(从这里可以看出,除了 dispatch 之外,在函数类型的 Action 内部还可以访问 getState 和 extraArgument)</p><p>当异步操作完成,调用 Redux-thunk 传递的 dispatch 方法提交对象类型 Action 时,还是进入这个被修饰的 dispatch 方法,只不过在判断类型的时候,进入了另一个分支:</p><pre><code class="js">return next(action);</code></pre><p>这里的 next 就是 Redux 原本的 dispatch 方法,会将对象类型的 Action 提交给 reducer 方法,最终执行状态更新。</p><h2>5. 总结</h2><p>Redux 是一种非常经典的状态管理解决方案。它遵循函数式编程的原则,状态只读且不可变,只有通过纯函数才能更新状态。</p><p>但是 Redux 同样也存在着不少问题。首先,对于新手来说,上手成本较高,使用之前需要先了解函数式编程的概念和设计思想。其次,Redux 在实际开发中非常繁琐,即使实现一个很简单的功能,可能也需要同时修改 4-5 个文件,降低了开发效率。作为对比,Vuex 的上手成本非常低,对于新手非常友好,使用也非常简单,既不需要异步中间件,也不需要额外的 UI binding ,在 Redux 中通过插件提供的功能,全部内置开箱即用。</p><p>对此,Redux 官方提供了一个封装方案 Redux Toolkit,社区也提供了很多封装方案,例如 Dva 、Rematch 等等,旨在简化 Redux 的使用,API 的封装上很多地方就是参考了 Vuex 。甚至还出现了酷似 Vue 响应式、使用可变数据(Mutable)的 Mobx 状态管理方案。此外,React 官方团队也在近期推出了 Recoil 状态管理库。</p><h2>参考</h2><blockquote><p><a href="https://link.segmentfault.com/?enc=HdMUKLULmt5ymmMsQpcWbw%3D%3D.ltCmOVjM5hmgiC6TPhgU8xzmTnunJl6UvGHNn0h1oXI%3D" rel="nofollow">https://redux.js.org</a></p><p><a href="https://link.segmentfault.com/?enc=1hw%2BE3f4UxSI%2BY%2BDY7K1Og%3D%3D.639aTyReAXeOE49qPAGXpjHtnKmlEaPjZD4y%2B5MY9YMiVlMXmpIoptEd1dwn56rs" rel="nofollow">https://github.com/reduxjs/redux</a></p><p><a href="https://link.segmentfault.com/?enc=cXXT%2BY9GjT5mS%2F1ruh47cg%3D%3D.7zIsIJtkMSDZeVTT22O8XRbwITpGVLTQD808Vyn6O%2FA8J1ldUJa%2BHyOxRXInQzTO" rel="nofollow">https://github.com/reduxjs/redux-thunk</a></p><p><a href="https://link.segmentfault.com/?enc=%2FV1yzfDJo7hX9eBpPEFpFA%3D%3D.aPiKecQGnlU2%2B%2B7iALj%2BIsBk3XpHOd82GsI%2FcJSm1b1eSq02%2B%2FNq5EmcZqp%2FE20SHtRYPxdbCiD87BMZWOPFlQsW4mev1m9vvamqlyzhsKgSR7Hs1RyRRfF2FJg4jd2cXzDsm7ujQfj1km%2FY46YwAQ%3D%3D" rel="nofollow">https://stackoverflow.com/questions/35411423/how-to-dispatch-a-redux-action-with-a-timeout/35415559#35415559</a></p><p><a href="https://link.segmentfault.com/?enc=ie5anQ9r%2FgI4jZohpoHqDw%3D%3D.pc8AC%2BgFCjbqlS4bqC786UzN8ux0Ktmx3eISioFXVBFUdw60UvPBw17MfuOFP9lf" rel="nofollow">coding优雅指南:函数式编程</a></p></blockquote>
测试在项目流程中的那些事儿
https://segmentfault.com/a/1190000041561545
2022-03-17T11:33:05+08:00
2022-03-17T11:33:05+08:00
有道AI情报局
https://segmentfault.com/u/youdaotec
0
<h3>前言</h3><p>测试作为整个项目中的一环,在项目流程中起着不可或缺的作用。部分团队是缺少项目管理角色的,这个时候,测试对项目流程的推进、项目质量的保证显得尤为重要。好的测试,能在整个项目流程中以QA角度做好项目管理和及时的风险预警,让项目如期上线且保障质量。业界一直强调测试前置,那么测试在项目中,如何根据项目情况做前置工作推进项目流程,让大家都开心工作呢?本文以自己所在的项目组为例讲述项目流程中的一些事,希望可以与大家一同探讨~</p><h3>一、QA在项目中扮演的角色</h3><p>【why】明确目标是什么:明确做这个项目的目标是什么,可适当根据目标对需求实现、项目质量、研发提测时间点等做一些调节。</p><p>【when】项目的deadline:考虑项目组的特殊性,我们需要知道项目需要什么时候上线,明确项目deadline,根据时间节点制定合适的测试计划</p><p>【what】各阶段我们需要做什么:可以重点关注项目流程中,QA参与与输出的环节。有输入才会有输出,所以输出的环节往往是需要QA花费时间去思考的地方。</p><p>【how】遇到风险点时怎么做:测试阶段,除了QA环节的风险点需要及时暴露和push外,这个阶段研发和产品也在做一些工作。在项目流程管理中,作为最下游的参与者,需要关注这些风险点,及时暴露和push解决。</p><p>【who】QA、RD、PM</p><h3>二、我们面临的挑战</h3><h4>2.1挑战点</h4><p>1.发版频率在排名第二,2021全年发版71次,相当于每周都有一个版本在进行迭代,快速迭代的节奏, 对人效和团队协同效率要求高。<br><img src="/img/remote/1460000041561547" alt="" title=""><br>2.整个2021年,研发人均bug数为123个,bug较多, 提测质量不高。为了不拉长项目周期, 保障较短的bugfix时间非常关键,同时要考虑如何提高提测质量。<br><img src="/img/remote/1460000041561548" alt="在这里插入图片描述" title="在这里插入图片描述"></p><p>3.整个2021年,测试人均提bug量最多,在项目节奏紧张的情况下,发现和提bug的效率必须提升。<br><img src="/img/remote/1460000041561549" alt="在这里插入图片描述" title="在这里插入图片描述"></p><h4>2.2关于提测质量</h4><p>针对上述挑战的内容,我们可以看到提测质量上,我们存在不足之处。我们之前做过提高冒烟用例比例、冒烟交叉执行、时间预估增加冒烟时间等尝试,最后发现收获的效果有限。主要原因如下:</p><ol><li>多方合作、项目有固定deadline:由于项目特殊性,部分需求是多方合作的模式且有固定的deadline,就需要项目尽快上线,在对项目效率有极高要求的情况下,我们允许带一些层级深的bug上线,针对上线情况做hot<br>fix。</li><li>项目节奏紧张,需快速迭代更新:现有研发团队是串行的节奏,能持续高效迭代,为保证项目节奏的稳定性,避免出现因一个项目周期拉的过长导致节奏紊乱,我们接受分步提测的形式,就有可能出现冒烟功能不完整的情况,导致提测质量不如预期。</li></ol><p>基于以上原因,我们可以看到在质量与效率之间需要做一定的选择时,需要向项目效率倾斜,所以我们既然无法更好地改变提测质量,那就去改变我们能改变的。</p><h3>三、面对这些挑战,QA可以做什么</h3><p>QA可以做什么让整个迭代周期变短,在bug很多的情况下还能快速迭代且线上问题较少呢?先来看下我们的项目流程:<br><img src="/img/remote/1460000041561550" alt="在这里插入图片描述" title="在这里插入图片描述"></p><p>从整个项目流程上看,可能与很多团队如出一辙。在流程上,QA作为下游的一个部分,可以看到QA参与输出的内容其实有很多,这些部分就是我们可以尝试去改变提升的点。<br>那么我们从这些输出内容看下,面对上述挑战,<strong>QA都做了哪些改变以及还有哪些困境。</strong></p><h4>3.1项目排期计划</h4><p>项目排期计划模板:<br><img src="/img/remote/1460000041561551" alt="在这里插入图片描述" title="在这里插入图片描述"></p><p>【when】项目排期一般是需求评审完后,根据需求拆分需求模块和开发模块。<br>排期计划中,QA的工作:熟悉需求,拆分需求模块,制定测试计划<br>QA同学加入进模块拆解,能更好的了解需求,拆分的开发模块也能更快的知道当有bug时,bug是属于哪个端的,提给哪位对应的开发。<br>根据各模块的提测时间和大致开发周期,QA同学也能制定对应的测试计划。</p><p>【what】-- QA具体需要做什么</p><ol><li>协助开发拆分功能模块,确保模块都有对应的开发负责人</li><li>确认项目deadline、开发总预计时间和提测时间</li></ol><h4>3.2测试计划制定</h4><p>项目测试计划模板:<br><img src="/img/remote/1460000041561552" alt="在这里插入图片描述" title="在这里插入图片描述"></p><p>【when】测试计划一般在项目排期给出后1天内提供,后续根据排期动态调整</p><p>测试计划中,QA的工作:根据需求预估时间和人力,明确测试环境与策略,制定合理的测试计划,预估风险</p><p>【what】-- QA具体需要做什么</p><p>1.拆分功能模块,模块明确好对应的测试。(包含用例编写安排、一、二轮测试安排和兼容测试安排)</p><p>2.预估好项目的总体测试时间和各轮次的测试时间</p><p>3.一轮接近尾声时,与开发明确好上预发时间;二轮接近尾声时,与开发明确好上online环境的时间</p><p>4.如有数据配置项,二轮测试开始前与产品明确好配置所需内容和完成时间节点</p><p>以上1、2两点尽早提供,其余可在对应时间点给出。当然,如遇到需求变更、人力变更等需要及时提出和调整。 </p><p>【how】-- 具体怎么做</p><p>根据开发排期,动态制定和调整合理测试的计划。</p><ul><li>根据提测时间,决定用例执行顺序与分配:<br>如下图拆分的测试计划,后台配置(星火)与用户端提测时间不一致,结合两个提测时间点,我们利用用户端提测前的时间,先执行后台配置的用例,这样即使是分步提测,我们也能确保每次提测时测试资源能跟上。<br><img src="/img/remote/1460000041561553" alt="在这里插入图片描述" title="在这里插入图片描述"></li><li>根据功能制定测试轮次<br>对于主干功能:需要多次执行测试用例,一般制定三轮的测试,一轮在测试环境,二轮预发环境,三轮线上环境<br>对于对内的、不影响用户使用的功能:制定一轮测试,在测试环境测一轮。比如星火等配置后台是给运营使用的,做一轮测试,上预发后产品走查验证+配置内容即可<br>活动类的功能:依据活动的复杂程度和使用频次,制定测试轮次。比如新年活动,是一次性的活动且活动时间紧,评估后我们在预发做了一轮测试就上线了,上线质量也一样较好。具体测试流程:活动类测试流程尝试<br><img src="/img/remote/1460000041561554" alt="" title=""></li><li>按照模块、用例量与难度划分,制定每人每天用例执行目标<br>一轮测试模块划分根据用例编写与熟悉度划分</li><li>实行交叉测试,避免因不熟悉导致遗漏或效率降低<br> 二轮进测试进行交叉,利用TC平台的任务指派,也可以清楚看到组员的任务数量与完成情况。</li></ul><p>如下图,测试计划的拆解与人员分配,细致划分到每人每日的工作目标,且各模块的分配会进行交叉,一轮测试人员发现用例不完善或测试不方便的地方也即使提供了文档以便二轮人员尽快上手测试。<br><img src="/img/remote/1460000041561555" alt="" title=""><img src="/img/remote/1460000041561556" alt="在这里插入图片描述" title="在这里插入图片描述"></p><p>【小结】:我们可以看到,调整测试计划的4种方式,主要目的都是通过这些办法去更高效地去完成测试任务,保障项目如期上线;更完善、全面地去发现bug,提升项目质量。测试计划的合理调整分配,是面对项目过程中各种挑战的有效方式之一。</p><h4>3.3jira定制化流程</h4><ol><li>定制化的jira项目流程:<br><img src="/img/remote/1460000041561557" alt="在这里插入图片描述" title="在这里插入图片描述"></li></ol><p>版本发布管理三部曲:<br><img src="/img/remote/1460000041561558" alt="在这里插入图片描述" title="在这里插入图片描述"></p><ul><li>jira版本发布管理:从产品建立版本开始,到最终复盘,整个流程和数据统计都体现在jira看板中,方便统一管理</li><li>项目进度自动同步:如下,项目组成员能很清晰的知道当前项目进度,且版本进度每天都会自动在大群同步;完结的项目,也会根据项目情况自动同步复盘信息<br><img src="/img/remote/1460000041561559" alt="在这里插入图片描述" title="在这里插入图片描述"><img src="/img/remote/1460000041561560" alt="" title=""></li></ul><p>【小结】:</p><p>1.定制化的流程,让流程更加统一规范和智能化。</p><p>2.关键信息的及时同步,能减少每日站会、信息同步会等重复会议,节约了时间。<br>各团队之前的协作更加顺畅,那团队协同效率和人效也就自然而然能进一步提高。</p><ol start="2"><li>QA高效提bug、研发快速修bug秘诀:</li></ol><p>2021Q1 效率工具的需求收集提效讨论中,提bug流程的优化建议一一实现了,每个人提bug 的速度大幅提升,主要汇总如下:</p><ul><li>bug区分问题类型 —— 使bug分类更精准,能更好地分析数据,push对应人员</li><li>bug状态展示优化 —— 各状态一目了然,更快找到需要处理的bug</li><li>bug描述预置版本、步骤、设备等信息 —— 减少重复内容输入,提bug效率更高</li></ul><p><img src="/img/remote/1460000041561561" alt="" title=""><br><img src="/img/remote/1460000041561562" alt="[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-jr4sSSa1-1647325340880)(http://pfp.ps.netease.com/kmspvt/file/6229b793cd05488340940b7dYcR67yhq01?sign=gL7e5CD91maZDjs7XkFpJTX3Oa4=&expire=1646902939)]" title="[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-jr4sSSa1-1647325340880)(http://pfp.ps.netease.com/kmspvt/file/6229b793cd05488340940b7dYcR67yhq01?sign=gL7e5CD91maZDjs7XkFpJTX3Oa4=&expire=1646902939)]"></p><ul><li>jira移动版接入使用 —— 附件内容更方便上传,bug描述更准确,减少因无法复现、描述不清等原因带来的重复沟通成本<br><img src="/img/remote/1460000041561563" alt="" title=""></li><li>bug流程新增:一轮漏测、fix bug引入选项、bug描述不清的状态 —— 当然这些指标目的不是为了追究是开发或是测试的责任,是为了分析bug,总结原因,从中找出不足的地方(比如用例设计不完善、开发修复bug未自测等问题),大家共同进步,提升项目质量,从而让项目进行更流畅与高效。</li><li>自动提醒开发QAfix和验收bug:—— 精准找到需要处理bug,处理效率大大提升<br>项目流程复盘中,我们约定p1bug当天需要fix,p2bug原则上fix周期不超过T+1天,验收不超过T+2天。如下图,就是根据形成的规范自动提醒研发、测试的内容:<br><img src="/img/remote/1460000041561564" alt="" title=""><br><img src="/img/remote/1460000041561565" alt="在这里插入图片描述" title="在这里插入图片描述"></li></ul><p>【小结】:</p><p>1.即使是预置的一些提bug信息和界面优化,也让测试更“优雅”地工作,提bug和验bug也更有劲儿了。</p><p>2.T+1修复周期的约定与消息推送,给了研发一个心里预期,正如我们根据项目情况调整测试策略一般,研发也根据我们给的预期调整了工作模式,从而使研发fix bug周期保障到最短,高效且有质量地修复了bug。 </p><p>工作流程的调整与满满预期的加持,让整个团队的工作效率极大提高。 </p><h4>3.4测试报告</h4><ol><li>测试日报</li></ol><p>【when】一般项目提测后,需要每日下班前发送日报</p><p>【what】-- QA具体需要做什么</p><p>汇总其他QA的进度,根据项目情况发送日报or周报。<br>日报中风险项一环节可根据项目情况修改,同步计划、提醒事项等都可以写入。<br>push开发fix bug:p1 修复周期不超过T+1天,bug数量较多时,可根据测试情况适当催开发修改(比如一轮测试接近尾声,还有很多服务端前端bug,就需要催一下了)</p><p>【how】-- 具体怎么做</p><p>在galaxy平台工具上,实现了日报自动生成工具,每日可自动生成日报内容,方便大家看进度,且日报中还有当前bug状态和链接,研发也能更快找到自己的bug。<br>日报一键生成效果如下:<br><img src="/img/remote/1460000041561566" alt="" title=""><br>【小结】:</p><p>日报的自动生成,节省了测试每日汇总进度的时间,更是直接大幅减少了关键信息的沟通同步成本,是人效和团队协同效率提升的又一次加成buff。</p><ol start="2"><li>质量报告(测试报告)</li></ol><p>【when】项目上线后,对项目进行总结梳理</p><p>【how】-- 具体怎么做<br>结合jira的使用流程,可一键生成测试报告并同步质量平台。<br><img src="/img/remote/1460000041561567" alt="" title=""><br>生成的测试报告示例:<br><img src="/img/remote/1460000041561568" alt="" title=""></p><h4>3.5项目复盘</h4><p>【when】项目上线后的一周内,小型项目如有必要可合并组织</p><p>【why】复盘的目的:针对项目中不足之处,共同讨论对策,争取下次做的更好</p><p>【what】-- QA具体需要做什么</p><p>1.数据文档准备:形式其实不做限制,需要的数据、文档等准备好即可,也可以与开发轮流组织。</p><p>2.会议上形成的todo list需要进行跟进处理</p><p>【how】-- 具体怎么做</p><p>复盘例子:<br><img src="/img/remote/1460000041561569" alt="" title=""><br>复盘提效jira看板:如下图 — ps:催bug或者发日报的时候也可以使用,比较清晰<br><img src="/img/remote/1460000041561570" alt="" title=""></p><p>【小结】:定期做项目复盘,让团队意识到我们当前存在的问题,推进项目流程一次比一次做的更好。</p><p>【不足】:<br>当然,在复盘过程中,各团队虽然达成一些共识共同改进,也遇到了一些列问题。</p><p>问题一:项目节奏已经很紧张的情况下,大家可能都在赶项目进度,没有余力去做复盘总结工作,追求效率从而忽视了质量。</p><p>问题二:复盘形成的todolist也没时间去跟进,导致复盘的总结内容最后不了了之,复盘失去意义。</p><p>问题三:一些复盘改进点,往往由于各种特殊原因而不能按规定执行 。</p><p>基于以上原因,复盘收获的效果是比较有限的,也是我们今后需要探讨与改进的一个命题。</p><h3>四、项目风险</h3><h4>4.1风险评估</h4><p>项目流程中,我们关注各个阶段需要做什么事的同时也会做项目管理与把控,关注项目风险,守住deadline。</p><p>风险可以分为两类:质量风险和进度风险</p><p>举个例子:<br>用例编写的时间不够,影响测试时间和上线时间,我们称之为进度风险;而用例编写时,编写用例人员不熟该功能,用例覆盖不足,我们可以称之为质量风险。</p><p>这里我们主要关注的是项目进度,所以着重关注进度风险一项。进度风险,就是在项目进度中出现的风险从而影响了整个项目的时间点。</p><p>在测试计中,我们设计了风险一栏放于第一位,目的就是让QA在项目流程中,及时从测试角度去观测和记录风险。</p><p>比如:<br><img src="/img/remote/1460000041561571" alt="" title=""><br><img src="/img/remote/1460000041561572" alt="在这里插入图片描述" title="在这里插入图片描述"></p><h4>4.2风险对策</h4><p>面对风险出现时,需要case by case讨论。在进度风险出现时,首要原则就是及时暴露风险、寻找方法去尽可能降低风险。</p><p>项目组很多项目因与其他部门配合,有固定deadline并且允许有部分已知问题带上线,那么我们一般从测试开发角度去商议的解决办法如下:</p><p><img src="/img/remote/1460000041561573" alt="" title=""><br>以上方案如果还不能守住deadline,就要考虑项目延期。</p><h2>结语</h2><p>上述内容是作者所在项目组结合已有的测试流程,针对项目遇到的挑战进行流程推进以及推进后的总结介绍。<br>鉴于不同项目组的特殊和差异性,文中提到的方法和手段可能只是冰山一角,不一定完全适用各类项目。根据项目情况做前置工作推进项目流程,其实是一个很大的命题,不同项目组有时存在的问题也不尽相同,测试在项目流程中还能做哪些更 nice 的事,还是需要靠大家在现有情况下去进行探索和总结。也欢迎大家留言与我们交流讨论~</p>
程序设计优化之管道数据流
https://segmentfault.com/a/1190000041521160
2022-03-10T10:30:00+08:00
2022-03-10T10:30:00+08:00
有道AI情报局
https://segmentfault.com/u/youdaotec
5
<blockquote><p>摘要</p><p>计算机基础的同学估计对管道这个词都不陌生了,尤其是在Linux系统当中,管道操作符已经被广泛的使用,并给我们的变成带来了极大的便利。前端领域比较注明的脚手架“gulp”也是以其管道操作著称。<br>今天我们就来一步步抽丝剥茧,看看在前端领域的“管道数据流”要如何设计。</p></blockquote><h2>一、前言</h2><p>有计算机基础的同学估计对管道这个词都不陌生了,尤其是在Linux系统当中,管道操作符已经被广泛的使用,并给我们的变成带来了极大的便利。管道操作通常分为单向管道和双向管道,当数据从上一节管道流向下一节管道时,我们的数据将会被这节管道进行一定的加工处理,处理完毕后送往下一节管道,依次类推,这样就可以对一些原始的数据在不断的管道流动中进行不断的加工,最后得到我们想要的目标数据。<br>在我们日常编程开发过程中,也可以尝试使用管道数据的概念,对我们的程序架构进行一定的优化,让我们程序的数据流动更加清晰明了,并可以让我们像是流水线一样,每个管道专门负责各自的工作对数据源进行一次粗加工,达到职责分明与程序解耦的目的。</p><h2>二、程序设计</h2><p>现在我们使用Typescript实现一个基础的管道类的设计,我们今天使用的管道是单向管道。<br><img src="/img/remote/1460000041521162" alt="在这里插入图片描述" title="在这里插入图片描述"></p><h3>2.1 Pipe-转接头</h3><p>顾名思义,转接头就是需要将不同的多节管道连接在一起成为一整条管道的连接口,通过这个连接头,我们可以控制数据的流向,让数据流向他真正该去的的地方。<br>首先,我们来设计一下我们的转接头的类型结构:</p><pre><code class="typescript">type Pipeline<T = any> = {
/**
* 将多节管道链接起来
* e.g.
* const app = new BaseApp();
* app.pipe(new TestApp1()).pipe(new TestApp2()).pipe(new TestApp3()).pipe(new Output()).pipe(new End())
* @param _next
*/
pipe(_next: Pipeline<T>): Pipeline<T>;
};</code></pre><p>上述代码描述了一个支持管道数据的类需要有怎样的一个转接头,在程序设计中,我们的转接头其实就是一个函数,用于将多节管道相互链接。<br>从上面的代码大家可以看出,为了程序的高复用,我们选择对管道中传输的数据类型进行泛型化,这样,我们再具体实现某一个程序时,便可更加灵活的使用其中类型,例如:</p><pre><code class="typescript">// 时间类型的管道
type DatePipeline = Pipeline<Date>
// 数组类型的管道
type ArrayPipeLine = Pipeline<string[]>
// 自定义数据类型的管道
type CustomPipeLine = Pipeline<{name: string, age: number}></code></pre><p>除此之外,我们这个函数的传入参数和返回值也是有讲究的,从上面的代码可以看出,我们接收一个管道类型的数据,又返回一个管道类型的数据。其中,参数中传入的便是下一节管道,这样,我们就把两节管道连接到了一起。之所以要返回一个管道类型的数据,是为了让我们使用时可以链式调用,更符合管道数据的设计理念,如:</p><pre><code class="typescript">const app = new AppWithPipleline();
app.pipe(new WorkerPipeline1())
.pipe(new WorkerPipeline2())
.pipe(new WorkerPipeline3())
.pipe(new WorkerPipeline4())</code></pre><p>也就是说,我们返回的,其实也是下一节管道的引用。</p><h3>2.2 Push-水泵</h3><p>有了转接头之后,我们还需要一个“水泵”将我们的数据源源不断地推送到不同的管道,最终到达目标点。</p><pre><code class="typescript">type Pipeline<T = any> = {
/**
* 实现该方法可以将数据通过管道一层层传递下去
* @param data
*/
push(data: T[]): Promise<void>;
/**
* 将多节管道链接起来
* e.g.
* const app = new BaseApp();
* app.pipe(new TestApp1()).pipe(new TestApp2()).pipe(new TestApp3()).pipe(new Output()).pipe(new End())
* @param _next
*/
pipe(_next: Pipeline<T>): Pipeline<T>;
};</code></pre><p>为了适应更多场景,我们设计这个水泵接受一个T[]类型的数组,在第一节管道当中,当我们拿到了初始的数据源时,我们就可以利用这个水泵(方法)将数据推送出去,让后面的每一个加工车间处理数据。</p><h3>2.3 resolveData - 加工车间</h3><p>当我们的数据被推送到某一节管道时,会有一个加工车间对推送过来的数据根据各自不同的工序进行粗加工。<br>注意:我们每一个加工车间应该尽可能保证职责分离,每个加工车间负责一部分的工作,对数据进行一次粗加工,而不是把所有的工作都放到一个加工车间当中,否则就失去了管道数据的意义。</p><pre><code class="typescript">type Pipeline<T = any> = {
/**
* 实现该方法可以将数据通过管道一层层传递下去
* @param data
*/
push(data: T[]): Promise<void>;
/**
* 将多节管道链接起来
* e.g.
* const app = new BaseApp();
* app.pipe(new TestApp1()).pipe(new TestApp2()).pipe(new TestApp3()).pipe(new Output()).pipe(new End())
* @param _next
*/
pipe(_next: Pipeline<T>): Pipeline<T>;
/**
* 用于接受从上一节管道传递下来的数据,可进行加工后传递到下一节管道
* @param data
*/
resolveData(data: T[]): T[] | Promise<T[]>;
};</code></pre><p>加工车间依旧是接收一个T[]类型的数据数组,拿到这个数据后,按照各自的工序对数据进行加工处理,加工好之后,重新放回流水线的传送带上(返回值),送往下一节管道的加工车间继续加工。</p><h2>三、具体实现</h2><p>上面我们只是定义了一个管道应该有的最基本的行为,只有具备以上行为能力的类我们才认为它是一节合格的管道。那么,接下来,我们就来看看一个管道类需要如何实现。</p><h3>3.1 基础管道模型类</h3><pre><code class="typescript">class BaseApp<P = any> implements Pipeline<P> {
constructor(data?: P[]) {
data && this.push(data);
}
/**
* 仅内部使用,下一节管道的引用
*/
protected next: Pipeline<P> | undefined;
/**
* 接受到数据后,使用 resolveData 处理获得新书局后,将新数据推送到下一节管道
* @param data
*/
async push(data: P[]): Promise<void> {
data = await this.resolveData(data);
this.next?.push(data);
}
/**
* 链接管道
* 让 pipe 的返回值始终是下一节管道的引用,这样就可以链式调用
* @param _next
* @returns
*/
pipe(_next: Pipeline<P>): Pipeline<P> {
this.next = _next;
return _next;
}
/**
* 数据处理,返回最新的数据对象
* @param data
* @returns
*/
resolveData(data: P[]): P[] | Promise<P[]> {
return data;
}
}</code></pre><p>我们定义了一个实现了Pipleline接口的基础类,用来描述所有管道的样子,我们所有的管道都需要继承到这个基础类。<br>在构造函数中,我们接受一个可选参,这个参数代表我们的初始数据源,只有第一节管道需要传入这个参数为整个管道注入初始数据,我们拿到这个初始数据后,会使用水泵(push)将这个数据推送出去。</p><h3>3.2 管道统一数据对象</h3><p>通常在程序实现时,我们会定义一个统一的数据对象作为管道中流动的数据,这样更好维护与管理。</p><pre><code class="typescript">type PipeLineData = {
datasource: {
userInfo: {
firstName: string;
lastName: string;
age: number,
}
}
}</code></pre><h3>3.3 第一节管道</h3><p>由于第一节管道之前没有任何管道了,我们想要让数据流动起来,就需要在第一节管道处使用水泵给予数据一个初始动能,让他可以流动起来,因此,第一节管道的实现会与其他管道略有不同。</p><pre><code class="typescript">export class PipelineWorker1 extends BaseApp<PipeLineData> {
constructor(data: T[]) {
super(data);
}
}</code></pre><p>第一节管道主要的功能就是接受原始数据源,并使用水泵将数据发送出去,所以实现起来比较简单,只需要继承我们的基类BaseApp,并将初始数据源提交给基类,基类再用水泵将数据推送出去即可。</p><h3>3.4 其他管道</h3><p>其他管道每个管道都会有一个数据处理车间,用来处理流向当前管道的数据,因此我们还需要重写基类的resolveData方法。</p><pre><code class="typescript">export class PipelineWorker2 extends BaseApp<PipeLineData> {
constructor() {
super();
}
resolveData(data: PipeLineData[]): PipeLineData[] | Promise<PipeLineData[]> {
// 在这里我们可以对数据进行一些特定的处理
// 注意我们尽可能在传入的 data 上进行操作,保持引用
data.forEach(item => {
item.userInfo.name = `${item.userInfo.firstName} · ${item.userInfo.lastName}`
});
// 最后,我们再调用基类的 resolveData 方法,把处理好的数据传进去,
// 这样就完成了一道工序的加工了
return super.resolveData(data);
}
}
export class PipelineWorker3 extends BaseApp<PipeLineData> {
constructor() {
super();
}
resolveData(data: PipeLineData[]): PipeLineData[] | Promise<PipeLineData[]> {
// 在这里我们可以对数据进行一些特定的处理
// 注意我们尽可能在传入的 data 上进行操作,保持引用
data.forEach(item => {
item.userInfo.age += 10;
});
// 最后,我们再调用基类的 resolveData 方法,把处理好的数据传进去,
// 这样就完成了一道工序的加工了
return super.resolveData(data);
}
}
export class Output extends BaseApp<PipeLineData> {
constructor() {
super();
}
resolveData(data: PipeLineData[]): PipeLineData[] | Promise<PipeLineData[]> {
// 在这里我们可以对数据进行一些特定的处理
// 注意我们尽可能在传入的 data 上进行操作,保持引用
console.log(data);
// 最后,我们再调用基类的 resolveData 方法,把处理好的数据传进去,
// 这样就完成了一道工序的加工了
return super.resolveData(data);
}
}
// 我们还可以利用管道组装灵活的特性开发出各种各样的插件,可随时插拔
export class Plugin1 extends BaseApp<PipeLineData> {
constructor() {
super();
}
resolveData(data: PipeLineData[]): PipeLineData[] | Promise<PipeLineData[]> {
// 在这里我们可以对数据进行一些特定的处理
// 注意我们尽可能在传入的 data 上进行操作,保持引用
console.log("这是一个插件");
// 最后,我们再调用基类的 resolveData 方法,把处理好的数据传进去,
// 这样就完成了一道工序的加工了
return super.resolveData(data);
}
}</code></pre><h3>3.5 组装管道</h3><p>上面我们已经将每一节管道都准备好了,现在要把他们组装起来,投入使用了。</p><pre><code class="typescript">const datasource = {
userInfo: {
firstName: "kiner",
lastName: "tang",
age: 18
}
};
const app = new PipelineWorker1(datasource);
// 管道可以随意组合
app.pipe(new Output())
.pipe(new PipelineWorker2())
.pipe(new Output())
.pipe(new PipelineWorker3())
.pipe(new Output())
.pipe(new Plugin1());
</code></pre><h2>四、结语</h2><p>至此,我们就已经完成了一个管道架构的设计了。是不是觉得,使用了管道数据之后,我们的整个程序代码的数据流向更加清晰,每个模块之前的分工更加分明,模块与模块之前的项目配合更加灵活了呢?<br>使用管道设计,还能让我们可以额外扩充一个插件库,用户可以随意定制符合各个业务场景的插件,让我们的程序的扩展性变得极强。</p>
js 几种网络请求方式梳理——摆脱回调地狱
https://segmentfault.com/a/1190000041487847
2022-03-03T10:49:39+08:00
2022-03-03T10:49:39+08:00
有道AI情报局
https://segmentfault.com/u/youdaotec
7
<blockquote><strong>摘要</strong> <br>本文介绍了基于 XMLHttpRequest、Promise、async/await 等三种异步网络请求的写法,其中async/await 写法允许我们以类似于同步的方式编写异步程序,摆脱繁琐的回调函数。</blockquote><h2>一、背景</h2><p>为了应对越来越多的测试需求,减少重复性的工作,有道智能硬件测试组基于 electron 开发了一系列测试提效工具。</p><p>electron 的编程语言是js,因为大家都不是专业的前端,对js不太熟悉,在编写程序时踩了不少坑。尤其是js中的事件和网络请求,这些涉及到异步编程的地方很容易出错。</p><p>随着工具的快速开发迭代,代码中出现了越来越多的嵌套的回调函数,工具崩溃的几率也越来越大。为了解决这些问题,<strong>我们用 async/await 对这些回调函数进行了重构</strong>,使得代码量下降,代码的可读性和可理解性都有了大幅度提高。</p><p>本文介绍了<strong>基于 XMLHttpRequest、Promise、async/await 等三种异步网络</strong>请求的写法,其中 async/await 写法允许我们以类似于同步的方式编写异步程序,摆脱繁琐的回调函数。</p><h2>二、前言</h2><p>在js中如果只是发起单个网络请求还不算复杂,用fetch、axios或者直接用XMLHttpRequest就能满足要求。</p><p>但若是多个请求按顺序拉取数据那写起来就很麻烦了😂,因为js中的网络请求都是异步的,想要顺序执行<strong>最常见写法就是在回调函数中发起下一个请求</strong>,如下面这些代码:</p><pre><code class="typescript">const requestOptions = {
method: 'GET',
redirect: 'follow'
};
fetch('https://xxx.yyy.com/api/zzz/', requestOptions)
.then(response => response.json())
.then(data => {
fetch('https://xxx.yyy.com/api/aaa/'+data.id, requestOptions)
.then(response => response.json())
.then(data => {
console.log(data)
})
.catch(error => console.error('error', error));
})
.catch(error => console.error('error', error));</code></pre><p>假设我需要经过两步获取一个数据,如从<em><a href="https://link.segmentfault.com/?enc=FRmNebeQeSgGiDQkk5TUBQ%3D%3D.K8UQC1vHDWYHLJwPix7bA5RP%2F7AwI2aXA9ieNayHHig%3D" rel="nofollow">https://xxx.yyy.com/api/zzz</a></em>/获取一个数据对象data,通过data.id得到我要获取数据的序号,之后再发一次请求得到想要的数据。</p><p>用回调函数的方式就类似于上面这样,太繁琐了,而且容易出错,并且一旦逻辑复杂就不好改啦。</p><p>接下来梳理一下js的几种网络请求方式,摆脱回调地狱,希望对遇到类似问题的小伙伴有所帮助。</p><h3>(一)XMLHttpRequest</h3><p>首先是XMLHttpRequest,初学前端时大名鼎鼎的Ajax主要指的就是它。通过XMLHttpRequest对象创建网络请求的套路如下:</p><pre><code class="typescript">// 假设访问http://localhost:3000/user返回json对象{"name":"YouDao"}
const xhr = new XMLHttpRequest();
const url = 'http://localhost:3000/user'
xhr.onreadystatechange = function(){
if (this.readyState == 4 && this.status == 200){
const json=JSON.parse(xhr.responseText)
const name=json.name
console.log(name)
}
}
xhr.open('GET',url)
xhr.send()</code></pre><p>这段代码首先创建一个XMLHttpRequest对象xhr,然后给xhr.onreadystatechange添加readystatechange事件的回调函数,之后xhr.open('GET',url)初始化请求,最后由xhr.send()发送请求。</p><p>请求发送后,程序会继续执行不会阻塞,这也是异步调用的好处。当浏览器收到响应时就会进入xhr.onreadystatechange的回调函数中去。在整个请求过程中,xhr.onreadystatechange会触发四次,每次readyState都会自增,从1一直到4,只有到了最后阶段也就是readyState为4时才能得到最终的响应数据。到达第四阶段后还要根据status判断响应的状态码是否正常,通常响应码为200说明请求没有遇到问题。这段代码最终会在控制台上会打出YouDao。</p><p>可以看出,通过XMLHttpRequest处理请求的话,首先要针对每个请求创建一个XMLHttpRequest对象,然后还要对每个对象绑定readystatechange事件的回调函数,若是多个请求串起来,想想就很麻烦。</p><h3>(二)Promise</h3><p>Promise是在 ECMAScript 2015 引入的,如果一个事件依赖于另一个事件返回的结果,那么使用回调会使代码变得很复杂。Promise对象提供了检查操作失败或成功的一种模式。如果成功,则会返回另一个Promise。这使得回调的书写更加规范。</p><p><strong>通过Promise处理的套路如下:</strong></p><pre><code class="typescript">const promise = new Promise((resolve,reject)=>{
let condition = true;
if (condition) {
resolve("ok")
} else {
reject("failed")
}
}).then( msg => console.log(msg))
.catch( err => console.error(err))
.finally( _ =>console.log("finally"))
</code></pre><p>上面这段代码把整个处理过程串起来了,首先创建一个Promise对象,它的构造器接收一个函数,函数的第一个参数是没出错时要执行的函数resolve,第二个参数是出错后要执行的函数reject。</p><p>resolve指执行成功后then里面的回调函数,reject指执行失败后catch里执行的回调函数。最后的finally是不论成功失败都会执行的,可以用来做一些收尾清理工作。</p><p>基于Promise的网络请求可以用axios库或浏览器自带的fetch实现。</p><p><strong>axios库创建请求的套路如下:</strong></p><pre><code class="typescript">import axios from 'axios'
const url = 'http://xxx.yyy.com/'
axios.get(url)
.then(data => console.log(data))
.catch(err => console.error(err))</code></pre><p>我比较喜欢用fetch,fetch是用来代替XMLHttpRequest的浏览器API,它不需要导库,fetch创建请求的方式和axios类似,在开头已经展示过了就不重复写了。</p><p>虽然Promise把回调函数的编写方式简化了一些,但还是没有摆脱回调地狱,多个请求串起来的话就会像我开头写的那样,在then里面创建新的Promise,最终变成Promise地狱。</p><h3>(三)async/await</h3><p>async/await是在 ECMAScript 2017 引入的,可以简化Promise的写法,使得代码中的异步函数调用可以按顺序执行,易于理解。</p><p>下面就用开头的那个例子说明吧:</p><p>直接用fetch获取数据:</p><pre><code class="typescript">const requestOptions = {
method: 'GET',
redirect: 'follow'
};
fetch('https://xxx.yyy.com/api/zzz/', requestOptions)
.then(response => response.json())
.then(data => {
fetch('https://xxx.yyy.com/api/aaa/'+data.id, requestOptions)
.then(response => response.json())
.then(data => {
console.log(data)
})
.catch(error => console.error('error', error));
})
.catch(error => console.error('error', error));</code></pre><p>用async/await改写后:</p><pre><code class="typescript">async function demo() {
const requestOptions = {
method: 'GET',
redirect: 'follow'
};
const response = await fetch('https://xxx.yyy.com/api/zzz/', requestOptions);
const data = await response.json()
const response1 = await fetch('https://xxx.yyy.com/api/aaa/'+data.id, requestOptions)
const data1 = await response1.json()
console.log(data1)
}
demo().catch(error => console.error('error',error))</code></pre><p>改写后的代码是不是就很清楚了,没有那么多的then跟在后面了,这样如果有一连串的网络请求也不用怕了。</p><p>当async放在一个函数的声明前时,这个函数就是一个异步函数,调用该函数会返回一个Promise。<br>await用于等待一个Promise对象,它只能在异步函数中使用,await表达式会暂停当前异步函数的执行,等待 Promise 处理完成。</p><p>这样如果想让一连串的异步函数调用顺序执行,只要把被调用的这些函数放到一个用async修饰的函数中,调用前加上await就能让这些函数乖乖地顺序执行了。</p><blockquote><p><strong>结语</strong></p><p>通过本文的梳理,相信你已经知道怎样避免回调地狱了。不过需要注意的是 Promise 是2015年加入语言规范的,而 async/await 是2017年才加入到语言规范的,如果你的项目比较老或者是必须要兼容老版本的浏览器(如IE6😂),那就需要用别的方式来解决回调地狱了。<br>对于 electron 只要你用的是近几年的版本都是支持的,electron 可以当成是 chromium 和 node.js 的结合体,特别适合用来写跨平台的工具类桌面应用程序。</p></blockquote>
剖析react核心设计原理--异步执行调度
https://segmentfault.com/a/1190000041424705
2022-02-18T12:42:14+08:00
2022-02-18T12:42:14+08:00
有道AI情报局
https://segmentfault.com/u/youdaotec
5
<blockquote>JS的执行通常在单线程的环境中,遇到比较耗时的代码时,我们首先想到的是将任务分割,让它能够被中断,同时在其他任务到来的时候让出执行权,当其他任务执行后,再从之前中断的部分开始异步执行剩下的计算。所以关键是实现一套异步可中断的方案。那么我们将如何实现一种具备任务分割、异步执行、而且还能让出执行权的解决方案呢。React给出了相应的解决方案。</blockquote><h2>背景</h2><p>React起源于 Facebook 的内部项目,用来架设 Instagram 的网站,并于 2013 年 5 月开源。该框架主要是一个用于构建用户界面的 JavaScript 库,主要用于构建 UI,对于当时双向数据绑定的前端世界来说,可谓是独树一帜。更独特的是,他在页面刷新中引入了局部刷新的机制。优点有很多,总结后react的主要特性如下:</p><h3>1. 1 变换</h3><p>框架认为 UI 只是把数据通过映射关系变换成另一种形式的数据。同样的输入必会有同样的输出。这恰好就是纯函数。</p><h3>1.2 抽象</h3><p>实际场景中只需要用一个函数来实现复杂的 UI。重要的是,你需要把 UI 抽象成多个隐藏内部细节,还可以使用多个函数。通过在一个函数中调用另一个函数来实现复杂的用户界面,这就是抽象。</p><h3>1.3 组合</h3><p>为了达到可重用的特性,那么每一次组合,都只为他们创造一个新的容器是的。你还需要“其他抽象的容器再次进行组合。”就是将两个或者多个容器。不同的抽象合并为一个。</p><p><strong>React 的核心价值</strong>会一直围绕着目标来做<strong>更新</strong>这件事,将更新和极致的用户体验结合起来,就是 React 团队一直在努力的事情。</p><h2>变慢==>升级</h2><p>随着应用越来越复杂,React15 架构中,dom diff 的时间超过 16.6ms,就可能会让页面卡顿。那么是哪些因素导致了react变慢,并且需要重构呢。</p><p>React15之前的版本中协调过程是同步的,也叫stack reconciler,又因为js的执行是单线程的,这就导致了在更新比较耗时的任务时,不能及时响应一些高优先级的任务,比如用户在处理耗时任务时输入页面会产生卡顿。页面卡顿的原因大概率由CPU占用过高产生,例如:渲染一个 React 组件时、发出网络请求时、执行函数时,都会占用 CPU,而CPU占用率过高就会产生阻塞的感觉。如何解决这个问题呢?</p><p>在我们在日常的开发中,JS的执行通常在单线程的环境中,遇到比较耗时的代码时,我们首先想到的是将任务分割,让它能够被中断,同时在其他任务到来的时候让出执行权,当其他任务执行后,再从之前中断的部分开始异步执行剩下的计算。所以关键是实现一套异步可中断的方案。</p><p>那么我们将如何实现一种具备<strong>任务分割、异步执行</strong>、而且还能<strong>让出执行权</strong>的解决方案呢。React给出了相应的解决方案。</p><h3>2.1 任务划分</h3><p>如何单线程的去执行分割后的任务,尤其是在react15中更新的过程是同步的,我们不能将其任意分割,所以react提供了一套数据结构让他既能够映射真实的dom也能作为分割的单元。这样就引出了我们的Fiber。</p><p><strong>Fiber</strong></p><p>Fiber是React的最小工作单元,在React中,一切皆为组件。HTML页面上,将多个DOM元素整合在一起可以称为一个组件,HTML标签可以是组件(HostComponent),普通的文本节点也可以是组件(HostText)。每一个组件就对应着一个<strong>fiber节点</strong>,许多fiber节点互相嵌套、关联,就组成了<strong>fiber树(为什么要使用链表结构:因为链表结构就是为了空间换时间,对于插入删除操作性能非常好)</strong>,正如下面表示的Fiber树和DOM的关系一样:</p><pre><code class="typescript">Fiber树 DOM树
div#root div#root
| |
<App/> div
| / \
div p a
/ ↖
/ ↖
p ----> <Child/>
|
a</code></pre><p>一个 DOM 节点一定要着一个光纤节点节点,但一个光纤节点却非常有匹配的 DOM 节点节点。fiber作为工作单元的结构如下:</p><pre><code class="typescript">export type Fiber = {
// 识别 fiber 类型的标签。
tag: TypeOfWork,
// child 的唯一标识符。
key: null | string,
// 元素的值。类型,用于在协调 child 的过程中保存身份。
elementType: any,
// 与该 fiber 相关的已解决的 function / class。
type: any,
// 与该 fiber 相关的当前状态。
stateNode: any,
// fiber 剩余的字段
// 处理完这个问题后要返回的 fiber。
// 这实际上就是 parent。
// 它在概念上与堆栈帧的返回地址相同。
return: Fiber | null,
// 单链表树结构。
child: Fiber | null,
sibling: Fiber | null,
index: number,
// 最后一次用到连接该节点的引用。
ref:
| null
| (((handle: mixed) => void) & { _stringRef: ?string, ... })
| RefObject,
// 进入处理这个 fiber 的数据。Arguments、Props。
pendingProps: any, // 一旦我们重载标签,这种类型将更加具体。
memoizedProps: any, // 用来创建输出的道具。
// 一个状态更新和回调的队列。
updateQueue: mixed,
// 用来创建输出的状态
memoizedState: any,
mode: TypeOfMode,
// Effect
effectTag: SideEffectTag,
subtreeTag: SubtreeTag,
deletions: Array<Fiber> | null,
// 单链表的快速到下一个 fiber 的副作用。
nextEffect: Fiber | null,
// 在这个子树中,第一个和最后一个有副作用的 fiber。
// 这使得我们在复用这个 fiber 内所做的工作时,可以复用链表的一个片断。
firstEffect: Fiber | null,
lastEffect: Fiber | null,
// 这是一个 fiber 的集合版本。每个被更新的 fiber 最终都是成对的。
// 有些情况下,如果需要的话,我们可以清理这些成对的 fiber 来节省内存。
alternate: Fiber | null,
};</code></pre><p>了解完光纤的结构,那么光纤与光纤之间是如何并创建的链表树链接的呢。这里我们引出<strong>双缓冲机制</strong> </p><p>在页面中被刷新用来渲染用户界面的树,被称为 current,它用来渲染当前用户界面。每当有更新时,Fiber 会建立一个 workInProgress 树(占用内存),它是由 React 元素中已经更新数据创建的。React 在这个 workInProgress 树上执行工作,并在下次渲染时使用这个更新的树。一旦这个 workInProgress 树被渲染到用户界面上,它就成为 current 树。</p><p><img src="/img/remote/1460000041424707" alt="在这里插入图片描述" title="在这里插入图片描述"><br><strong>2.2 异步执行</strong></p><p>那么fiber是如何被时间片异步执行的呢,提供一种思路,示例如下</p><pre><code class="typescript">let firstFiber
let nextFiber = firstFiber
let shouldYield = false
//firstFiber->firstChild->sibling
function performUnitOfWork(nextFiber){
//...
return nextFiber.next
}
function workLoop(deadline){
while(nextFiber && !shouldYield){
nextFiber = performUnitOfWork(nextFiber)
shouldYield = deadline.timeReaming < 1
}
requestIdleCallback(workLoop)
}
requestIdleCallback(workLoop)</code></pre><p>我们知道浏览器有一个api叫做<strong>requestIdleCallback</strong>,它可以在浏览器空闲的时候执行一些任务,我们用这个api执行react的更新,让高优先级的任务优先响应。对于requsetIdleCallback函数,下面是其原理。</p><pre><code class="typescript">const temp = window.requestIdleCallback(callback[, options]);</code></pre><p>对于普通的用户交互,上一帧的渲染到下一帧的渲染时间是属于系统空闲时间,Input输入,最快的单字符输入时间平均是33ms(通过持续按同一个键来触发),相当于,上一帧到下一帧中间会存在大于16.4ms的空闲时间,就是说任何离散型交互,最小的系统空闲时间也有16.4ms,也就是说,离散型交互的最短帧长一般是33ms。</p><p>requestIdleCallback回调调用时机是在回调注册完成的上一帧渲染到下一帧渲染之间的空闲时间执行</p><p>callback 是要执行的回调函数,会传入 deadline 对象作为参数,deadline 包含:</p><p>timeRemaining:剩余时间,单位 ms,指的是该帧剩余时间。</p><p>didTimeout:布尔型,true 表示该帧里面没有执行回调,超时了。</p><p>options 里面有个重要参数 timeout,如果给定 timeout,那到了时间,不管有没有剩余时间,都会立刻执行回调<br> callback。</p><p>但事实是<strong>requestIdleCallback存在着浏览器的兼容性和触发不稳定的问题</strong>,所以我们需要用js实现一套时间片运行的机制,在react中这部分叫做scheduler。同时React团队也没有看到任何浏览器厂商在正向的推动requestIdleCallback的覆盖进程,所以React只能采用了偏hack的polyfill方案。</p><h2>requestIdleCallback polyfill 方案( Scheduler )</h2><p>上面说到requestIdleCallback存在的问题,在react中实现的时间片运行机制叫做scheduler,了解时间片的前提是了解通用场景下页面渲染的整个流程被称为一帧,浏览器渲染的一次完整流程大致为</p><p><strong>执行JS--->计算Style--->构建布局模型(Layout)--->绘制图层样式(Paint)--->组合计算渲染呈现结果(Composite)</strong></p><blockquote><p><strong><em>*帧的特性</strong>:</em>*</p><p>帧的渲染过程是在JS执行流程之后或者说一个事件循环之后</p><p>帧的渲染过程是在一个独立的UI线程中处理的,还有GPU线程,用于绘制3D视图</p><p>帧的渲染与帧的更新呈现是异步的过程,因为屏幕刷新频率是一个固定的刷新频率,通常是60次/秒,就是说,渲染一帧的时间要尽可能的低于16.6毫秒,否则在一些高频次交互动作中是会出现丢帧卡顿的情况,这就是因为渲染帧和刷新频率不同步造成的<br>用户通常的交互动作,不要求一帧的渲染时间低于16.6毫秒,但也是需要遵循<strong>谷歌的RAIL模型的</strong></p></blockquote><p>那么Polyfill方案是如何在固定帧数内控制任务执行的呢,究其根本是借助<strong>requestAnimationFrame让一批扁平的任务恰好控制在一块一块的33ms这样的时间片内执行。</strong></p><h2>Lane</h2><p>以上是我们的异步调度策略,但是仅有异步调度,我们怎么确定应该调度什么任务呢,哪些任务应该被先调度,哪些应该被后调度,这就引出了类似于微任务宏任务的<strong>Lane</strong></p><p>有了异步调度,我们还需要细粒度的管理各个任务的优先级,让高优先级的任务优先执行,各个Fiber工作单元还能比较优先级,相同优先级的任务可以一起更新</p><p>关于lane的设计可以看下这篇:</p><p><a href="https://link.segmentfault.com/?enc=88wUXXHuJUkpwBv4GoyJrQ%3D%3D.B8fJhqml7UjcGbj1TVrSjqoFZN5BVfGQ1lMoDo%2FFm9zzV48u4BOOvwGTNn9iNnl%2Fe%2FitaQ%2BlqcPbIqnUBJIQkgPL0spiZNnvG28l3RYTLswOjGW7mEJ5r2gUVbsQ1NN0" rel="nofollow">https://github.com/facebook/r...</a></p><h2>应用场景</h2><p>有了上面所介绍的这样一套异步可中断分配机制,我们就可以实现batchUpdates批量更新等一系列操作:<br><img src="/img/remote/1460000041424708" alt="在这里插入图片描述" title="在这里插入图片描述"><br>更新fiber前<br><img src="/img/remote/1460000041424709" alt="在这里插入图片描述" title="在这里插入图片描述"><br>更新fiber后</p><p>以上除了cpu的瓶颈问题,还有一类问题是和副作用相关的问题,比如获取数据、文件操作等。不同设备性能和网络状况都不一样,react怎样去处理这些副作用,让我们在编码时最佳实践,运行应用时表现一致呢,这就需要react有分离副作用的能力。</p><h2>设计serve computer</h2><p>我们都写过获取数据的代码,在获取数据前展示loading,数据获取之后取消loading,假设我们的设备性能和网络状况都很好,数据很快就获取到了,那我们还有必要在一开始的时候展示loading吗?如何才能有更好的用户体验呢?</p><p>看下下面这个例子</p><pre><code class="typescript">function getSomething(id) {
return fetch(`${host}?id=${id}`).then((res)=>{
return res.param
})
}
async function getTotalSomething(id1, id2) {
const p1 = await getSomething(id1);
const p2 = await getSomething(id2);
return p1 + p2;
}
async function bundle(){
await getTotalSomething('001', '002');
}</code></pre><p>我们通常可以用async+await的方式获取数据,但是这会导致调用方法变成异步函数,这就是async的特性,无法分离副作用。</p><p>分离副作用,参考下面的代码</p><pre><code class="typescript">function useSomething(id) {
useEffect((id)=>{
fetch(`${host}?id=${id}`).then((res)=>{
return res.param
})
}, [])
}
function TotalSomething({id1, id2}) {
const p1 = useSomething(id1);
const p2 = useSomething(id2);
return <TotalSomething props={...}>
}</code></pre><p>这就是hook解耦副作用的能力。</p><p>解耦副作用在函数式编程的实践中非常常见,例如redux-saga,将副作用从saga中分离,自己不处理副作用,只负责发起请求。</p><pre><code class="typescript">function* fetchUser(action) {
try {
const user = yield call(Api.fetchUser, action.payload.userId);
yield put({type: "USER_FETCH_SUCCEEDED", user: user});
} catch (e) {
yield put({type: "USER_FETCH_FAILED", message: e.message});
}
}</code></pre><p>严格意义上讲react是不支持Algebraic Effects的,但是借助fiber执行完更新之后交还执行权给浏览器,让浏览器决定后面怎么调度,Suspense也是这种概念的延伸。</p><pre><code class="typescript">const ProductResource = createResource(fetchProduct);
const Proeuct = (props) => {
const p = ProductResource.read( // 用同步的方式来编写异步代码!
props.id
);
return <h3>{p.price}</h3>;
}
function App() {
return (
<div>
<Suspense fallback={<div>Loading...</div>}>
<Proeuct id={123} />
</Suspense>
</div>
);
}</code></pre><p>可以看到ProductResource.read是同步的写法,把获取数据的部分分离出了Product组件之外,原理是ProductResource.read在获取数据之前会throw一个特殊的Promise,由于scheduler的存在,scheduler可以捕获这个promise,暂停更新,等数据获取之后交还执行权。这里的ProductResource可以是localStorage甚至是redis、mysql等数据库等。这就是我理解的server componet的雏形。</p><p>本文作为react16.5+版本后的核心源码内容,浅析了异步调度分配的机制,了解了其中的原理使我们在系统设计以及模型构建的情况下会有较好的大局观。对于较为复杂的业务场景设计也有一定的辅助作用。这只是react源码系列的第一篇,后续会持续更新,希望可以帮到你。</p><p>happy hacking~~</p>
LiveVideoStackCon | 面向在线教育业务的流媒体分发演进
https://segmentfault.com/a/1190000041414951
2022-02-17T10:00:00+08:00
2022-02-17T10:00:00+08:00
有道AI情报局
https://segmentfault.com/u/youdaotec
0
<blockquote>几年前,很多人对在线网课还非常陌生。随着移动设备的普及和音视频技术的发展,如今在线教育产品百花齐放。而在线教育产品能服务千万学子离不开流媒体分发技术的支撑。本次LiveVideoStackCon<br>2021 音视频技术大会北京站邀请到了网易有道研发工程师周晓天,为我们分享网易有道在线教育业务的流媒体分发相关内容。</blockquote><p>文 | 周晓天<br>整理 | LiveVideoStack</p><p>大家好,我来自网易有道精品课研发团队。如今音视频被各界广泛关注,“直播+”成为一个热点,大厂也纷纷推出了一系列音视频的相关服务。</p><p>网易有道是一家<strong>以成就学习者“高效学习”为使命</strong>的智能学习公司,依托强大的互联网AI等技术手段,围绕学习场景,打造了一系列深受用户喜欢的学习产品和服务。除了面向多种场景的在线教育平台,还有有道词典、有道词典笔等领先市场的软硬件学习工具。</p><p><img src="/img/remote/1460000041414953" alt="在这里插入图片描述" title="在这里插入图片描述"><br>其中在线教育业务就是依托音视频技术的不断成熟应运而生的一项重要业务。</p><p>音视频技术内容广、链条长、每个点又会很深。所以今天分享的内容以有道的在线教育业务为主题,聚焦在有道团队流媒体分发服务端的部分。</p><p><img src="/img/remote/1460000041414954" alt="在这里插入图片描述" title="在这里插入图片描述"></p><p>今天的内容分为三个部分,分别是有道在线教育业务介绍、分发系统架构的演进和对分发难点的思考与实践。</p><h2>1.在线教育业务介绍</h2><p>首先通过在线教育直播业务形态理解需求,明确媒体分发服务端要考虑什么。</p><p><img src="/img/remote/1460000041414955" alt="在这里插入图片描述" title="在这里插入图片描述"></p><p>不同班型对应着不同需求。2013年左右最先出现的是1V1课程、普通小班课。本质上是借助RTC实时通信模式构建的教育产品。后来游戏直播和娱乐直播被大家熟悉,而这个阶段被熟知的在线学习的主要形式是视频点播模式,比如网易公开课。随着音视频领域技术成熟,以及用户对在线教育需求的升级,直播网课迅速发展。直播课大约出现在2014年,在疫情后得到了空前的关注。</p><p><img src="/img/remote/1460000041414956" alt="在这里插入图片描述" title="在这里插入图片描述"></p><p>传统大班直播课是老师的单向推流,在互动大班课中,学生可以和老师进一步互动,获得更好的上课体验。学生连麦、屏幕/白板、老师视频和互动消息构成一节课的主要内容。</p><p>互动小班进一步优化产品的互动性,提升学员课堂参与感、学习体验与学习效果。音视频+H5互动组件+灵活的布局需求也带来额外复杂性。</p><p><img src="/img/remote/1460000041414957" alt="在这里插入图片描述" title="在这里插入图片描述"></p><p>面向业务设计服务,需要理解不同业务的差异再去采取相应的技术。这里提供一种思考的方式:以互动大班课为例,一个老师和一个学生正在连麦,再将连麦的过程分发给其他学生。对于流媒体分发,右侧列出一些考虑的要素:需要什么程度的延迟和流畅性?多大的规模?需要多高的媒体质量?当前业务线对方案成本的敏感度?</p><p><img src="/img/remote/1460000041414958" alt="在这里插入图片描述" title="在这里插入图片描述"></p><p>进一步可以用这种方式横向对比不同课程形态,通过它们的区别获得更精细的需求。</p><p>比如,对比大班直播课和互动大班课:对于规模为M的会话,大班直播课要把一个人的信息分发给M-1个人,这可以通过基于CDN的视频直播方式做到。如果进一步想要给产品增增加连麦互动性,成为互动大班课。连麦的增加会让简化模型变为两个部分,如何在一个教室内同时满足这两个需求?最简单的思路是在原有CDN分发的基础上,让连麦内容通过RTC方式交换,再将它们的信息通过原有CDN系统分发,但这么做会带来内容延迟和用户切换延迟等问题。</p><p>对比互动大班和(线上、线下)双师班级,虽然模型类似,但具体到场景中双师班级中的一个“学生端”可能对应一个线下教室的全体学生,这会增加单路分发异常的代价,这样的差异也就要求系统能对不同场景配置不同策略。</p><p><img src="/img/remote/1460000041414959" alt="在这里插入图片描述" title="在这里插入图片描述"></p><p>除了在线教育,横向对比的思路同样可以用来分析其他场景的业务线,例如普通小班和游戏开黑。开黑看似和只发送语音的普通小班课程类似,但是在性能和网络占用方面要求更严格。在尽量不占用游戏带宽的同时,还需要尽量减少CPU的操作,为游戏提供充足的算力。如果直接用小班课程的RTC接口用于游戏,保证通话质量的同时反而会影响游戏。如果期望使用一套系统支持多种业务,那么在系统设计早期就要明确业务差异和设计需求。</p><p><img src="/img/remote/1460000041414960" alt="在这里插入图片描述" title="在这里插入图片描述"></p><p>通过以上的分析,可以列出了在线教育业务对媒体分发系统的一些主要需求点。第一要满足分发低延迟、上麦低延迟。第二点要做大规模分发。相对一些娱乐场景,要做到高稳定以及高可用。第四点要对成本进行控制。最后,不同学生、不同教室对于上课场景的需求是不同的,所以一定要支持多端接入。</p><h2>2.分发架构的演进</h2><p><img src="/img/remote/1460000041414961" alt="在这里插入图片描述" title="在这里插入图片描述"></p><p>当多个业务线同时铺开的过程中,从1v1到小班、到大班直播、再到互动大班以及互动小班等课程,这会影响分发系统的演进过程。一种思路是随着业务的演变,分发架构逐渐复杂,不断支持越来越多的特性。有道并没有采用该思路,而是经历了从基于CDN的分发,到全部业务使用实时通信网络(RTN)的切换,没有架构上的中间过渡状态。</p><p>下面我们简单回顾一些分发架构作为普及内容。</p><p><img src="/img/remote/1460000041414962" alt="在这里插入图片描述" title="在这里插入图片描述"></p><p>基于CDN网络的直播内容分发的树状架构十分清晰,架构本身决定数据的路由,同时易于维护、风险和成本可控。当一个用户选定一个边缘接入,媒体数据的分发路由就已经规划好了。同时它有自身的缺点,比如:只支持单向分发、协议带来的固定延迟等。</p><p><img src="/img/remote/1460000041414963" alt="在这里插入图片描述" title="在这里插入图片描述"></p><p>早期通过CDN模式部署的直播为了增加互动性和降低延迟,在CDN架构的基础上做了两个优化。一方面在边缘拉流节点支持RTC的方式接入(图中也写为RTN边缘节点),从而屏蔽掉媒体封装协议带来的延迟、增加IM互动效果,同时还能增加弱网抗性。另一方面为了进一步增加互动性,增加了RTC旁路系统以支持双向连麦,再将连麦内容转推到CDN网络中完成直播。一些“低延时CDN直播”产品就采用这样的原理。</p><p><img src="/img/remote/1460000041414964" alt="在这里插入图片描述" title="在这里插入图片描述"></p><p>刚刚提到用于连麦的旁路RTC系统需要转推内容到CDN分发网络,那是否能让这个系统把CDN大规模分发的任务也一起做了呢?于是就有了纯RTN的架构。该架构不再有鲜明的树状分发结构,而是用一个网状拓扑分发所有内容。任意单向拉流客户端可以随时切换为双向通信,不需要先做系统的切换。</p><p><img src="/img/remote/1460000041414965" alt="在这里插入图片描述" title="在这里插入图片描述"></p><p>通过上述的分析,我们可以大致总结出业内直播流媒体分发演进的方向——音视频直播CDN和RTC网络边界模糊,逐步融为一体。直播CDN厂商逐渐从单向大规模分发支持低延迟接入、连麦。之前的RTC产品,从面向小型会议的架构逐步为了能够同时服务千人、万人,也开始将分发网络变复杂。所以现在我们能看到网易的WE-CAN分布式传输网、阿里云GRTN 流媒体总线、以及其它“X-RTN”都是该演进过程的结果。</p><p><img src="/img/remote/1460000041414966" alt="在这里插入图片描述" title="在这里插入图片描述"></p><p>刚刚提到的架构主要是ToB厂商的产品,在ToC服务的场景中也会有如上图所示的架构,通过一个媒体服务器融合两个分发网络提供服务,特别是对于同时有自研和三方接入时。该结构在带来新的非功能特性的同时,也有很大的风险。有道没有选择使用类似的架构进行过度,而是直接用RTN分发网络对原有功能进行替代。</p><p><img src="/img/remote/1460000041414967" alt="有道当前架构示意图" title="有道当前架构示意图"></p><p>该架构能满足多种场景的需求,也支持多种推拉流客户端接入。例如当同学上公开课时,通过微信小程序或者浏览器直接看是最为便捷的。已经使用课程APP、已经参加系列课程的用户,使用APP接入以获得最优体验。</p><p>相比CDN架构自身的拓扑结构决定了数据分发路由,RTN网状拓扑在带来灵活性的同时也增加复杂性。比如路由无法从拓扑直接获取,而是需要一个额外的调度中心去计算、规划路由,完成对应转发资源的调度,这也凸显了RTN架构下调度中心的重要性。</p><p>图中也有一个CDN旁路的部分,他的主要作用是做一些突发接入量过大的课程的负载均衡,增加系统的弹性。 </p><p>有道在设计网络节点拓扑的时候更偏向于灵活性。一方面,分发节点没有分层、分级,采用扁平拓扑。另一方面,通过配置不同的属性、角色可以实现对网络分发特性的改变。</p><h2>3.分发难点思考与实践</h2><p><img src="/img/remote/1460000041414968" alt="在这里插入图片描述" title="在这里插入图片描述"></p><p>对于流媒体分发系统有以下四个要点——接入问题、网络连通性、路由建立以及转发。除此之外还想分享一下关于分层设计和通道的概念。</p><p><img src="/img/remote/1460000041414969" alt="在这里插入图片描述" title="在这里插入图片描述"></p><p>解决接入问题的核心理念是“就近”接入——网络质量最好的接入为“最近”的接入。(不同类型的业务可能会有不同思路:有道的教学场景中力求现有每个用户体验尽可能最优,类似于贪心算法;但在别的业务中,思路可能会是在达到QoS最低限制的情况下选择全局成本最优的接入、路由方式)最直观的方法是使用基于IP、位置的接入推荐。进一步利用对不同网关网络探测、连接历史数据优化推荐的结果。除了利用线上、线下数据统计获得的先验的知识进行接入推荐,考虑到这样的方法无法涵盖所有特殊形况,有道还引入人工配置的支持。支持手工热配对部分ToC场景非常有效</p><p>右下角是一个大班课老师上行丢包率打点图,可以看到存在有规律的、平均在9%左右的丢包。该老师长期在固定地点使用固定设备进行直播,而且早期还有技术支持同学进行过网络检查,网络一直很好。按照之前的算法,他的位置没有变、网络没有变,使用的推荐数据库也变化不大,所以根据算法每次会给出相同的推荐结果。突然出现的有规律丢包推测是流量行为被运营商识别、分类,并对其进行了策略限制。</p><p>面对这种情况,修改算法是行不通的。通过有道热配置的方式,在发现问题进行上报的同时就可以人工修改配置,下一次老师接入会避开对应接入节点,解决丢包问题。</p><p>我们通过“过滤器”机制实现该操作:假如所有可接入节点构成一个池子,那么最终“过滤”出的结果构成推荐给客户端进行接入的列表。所以把过滤规则的计算过程作为算法写入系统,将算法执行要使用的参数作为可以热更新的数据写在数据库来实现。</p><p><img src="/img/remote/1460000041414970" alt="在这里插入图片描述" title="在这里插入图片描述"></p><p>接入只解决了分发网络的入口问题,那么分发网络究竟是怎样的拓扑形态呢?这就涉及到网络节点的连通性设计问题。有道的网络是一个扁平的拓扑,每个机房都是拓扑中扁平的点。理论上可以给所有节点之间都建立连接,成为一个mesh网络,那么这样的网络将会无比灵活,任意一条通路都可以被规划出来,完全依赖算法进行实际路由的选择。有道并没有采用这样的方式。</p><p>我们还是引入了一些人工经验,比如根据经验将一些机房的连通性删除,成为非Full mesh的结构。可以认为是借助人工的方式进行了剪枝、组织。除了连通性,在路由计算时还需要解决权重的获取问题,也就需要对节点连接情况差异进行量化描述。这种量化是基于规律性的QoS探测完成的,类似前面接入选择的问题,算法可能没法精细地满足所有case或者一些特殊情况,那么在量化差异外,我们也通过可配置的属性描述定性的差异来增加拓扑的灵活性。</p><p>之所以这样提高灵活性、支持人工配置,是为了能满足不同业务的差异化需求。同时也有代价,就是复杂性的提高。所以或许没有最好的架构,只有更合适的架构。</p><p><img src="/img/remote/1460000041414971" alt="在这里插入图片描述" title="在这里插入图片描述"></p><p>在确定了接入位置(明确了分发的起点和终点)、建立了分发网络的连通性后,要解决的就是路由规划或者说调度问题。这里可以为大家分享的实践和思考有三点:一条路由的规划、多路径还有成本控制。规划单条路由是完成数据分发的基础,我们根据动态探测、刷新的网络QoS量化质量和基于当前节点状况、节点配置共同完成路由权重的计算。有了无向带权图、有了终点和起点,就可以计规划一条最短分发路由。</p><p>解决了接入问题,又完成分发网络连通性定义,现在解决了媒体数据分发路由的规划,看似就可以完成分发任务了。但对于有道的业务要求这还不够,想进一步保障用户体验就需要提升分发网络对抖动、丢包的抗性。多路径分发是一种保障方式。有道分发网络有三种路径——主要路径、备选路径、实时路径。主要路径直接用于业务分发;备选路径是主要路径的备份,在规划主要路径时生成,当主要路径异常时切换。实时路径是在主要路径之外额外建立的多路冗余分发路径,以提供更加强大的分发抖动、丢包抗性,这对一些重点任务、大规模分发任务有很高价值。</p><p>以图上橙色线路为例。边缘是移动、联通和电信三个单线机房,除了主路径之外,可以在两个边缘的联通运营商之间建立实时路径,在实现实时备份的情况下降低备份线路成本。</p><p><img src="/img/remote/1460000041414972" alt="在这里插入图片描述" title="在这里插入图片描述"></p><p>控制中心完成数据分发路径的规划后,就需要沿途节点执行转发任务。这涉及到高性能流媒体分发服务器的设计。上图显示了有道的转发服务器线程模型。协议、端口对应不同的线程,从而在有限端口情况下尽可能利用多核资源。</p><p>除了每个协议-端口对会绑定一个IO线程,还有一个core线程,完成来自不同接入的数据包路由。比如一个推流用户从协议A端口A1接入(如使用UDP,从3000端口推流),同会话另一个拉流用户采用协议B端口B1接入(如使用TCP,从4000端口拉流),这两个用户根据IO线程模型不可能分配到同一个线程,所以需要进行跨线程数据转发。此时core线程会根据会话发布订阅的关系,将接收队列的内容向对应IO线程的队列进行转发。</p><p>该线程模型的设计和业务类型、比例也是相关的。当时系统负载以大班课为主,即推流人数大大小于拉流人数。如果业务类型发生变化,例如班型越来越小、课程每个成员都进行推流,而服务器总用户量如果不变,这会让core线程的转发负载相对大班课大大增加。这也是小班课业务带来的一项挑战,需要架构能随业务变化灵活应对。</p><p><img src="/img/remote/1460000041414973" alt="在这里插入图片描述" title="在这里插入图片描述"></p><p>除了上面四个关键问题外,借本次机会想额外分享、探讨两个细节:分层设计和通道的概念。</p><p>分层设计相当于转发问题的延伸。服务器拿到来自一个连接的数据以后,通过core线程分发。逻辑结构上可以理解为三层:链接层解决不同协议连入的问题;路由层负责处理数据在内部的分发、转移;会话层维护了发布订阅关系,指导路由进行分发,将数据发到正确的连接。该分层思想不仅用在单机线程模型中,也用在整个分发网络中。</p><p><img src="/img/remote/1460000041414974" alt="在这里插入图片描述" title="在这里插入图片描述"></p><p>当业务方接入一个实时通信SDK时,关于“通道”不同ToB厂商会有不同定义,简单理解就是对实时媒体传输资源的一种抽象。比如一些厂商所服务的业务场景的主要数据是人脸和屏幕共享,对应SDK可能就只提供两个通道资源,其中人脸通道支持大小流的同时推送。</p><p>上图以互动大班课为例介绍有道在“通道”设计方面的思考。左下角图片展示了互动大班的典型教师上课效果:右上角是主讲的老师,正在和左边的学生进行连麦,那么如何进一步把当前界面所有信息传递给其它学生?有道实时通信SDK提供了Live、RTC、Group等多个通道资源。SDK向外暴露的通道资源数量可以定义,同时可以差异化配置,虽然名字不同但是底层资源属于同一类。一个通道对应一路同步的音视频的分发能力。</p><p>仍以刚刚的场景为例:示意图左侧是教师,右侧是学生。橙色是RTC通道,这部分完成老师和学生的连麦。随后教师在端上进行混流——将连麦内容、课程白板等内容混为一路音视频通过Live通道向其它听课的学生发送。比如可以通过获取当前屏幕内容来做端上的混流。在互动大班型的业务场景下,所有学生需要获得信息都在这一张图里,都是视频和音频的媒体信息,这样就可以采取两个通道组合的方式,一个连麦、一个直播,从而完成整个业务。</p><p>不同的通道之所以有不同的名字而不是使用一个通道对象数组,是为了进一步降低客户端接入门槛。比如Live通道概念上相比RTC更强调流畅性,这可以对应一个更大的视频最小缓冲区来提升网络抖动抗性。</p><p>业务中发现SDK提供通道这种资源的方式可能会影响业务方的思考方式:如果只有“人脸通道”和“屏幕通道”,这可能会限制业务产品对新课程形式的思考。</p><h2>4.互动小班课为例</h2><p><img src="/img/remote/1460000041414975" alt="在这里插入图片描述" title="在这里插入图片描述"></p><p>借本次机会可以和大家分享有道关于互动小班的尝试,在以下两个方面和大家交流:小班的“互动”到底是怎样的?以及互动课程的录制问题。</p><p><img src="/img/remote/1460000041414976" alt="在这里插入图片描述" title="在这里插入图片描述"></p><p>在小班课中,多位学生和老师全程可以连麦。不同的同学可以随时被拉到台上进行分享、答题。除了音视频、白板这些基本内容之外,我们还加入了一些互动元素:本地媒体元素播放、多人实时互动棋盘等。这样的互动元素带来什么影响呢?</p><p>前面提到的互动大班课可以在端上混再发送到Live通道,这样流既可以省去需要单独服务端混流带来的视频延迟和同步问题,同时完整地传递了所有课程信息。但是对于互动小班课,如果老师端通过这种截取屏幕将内容分发给其他学生的方式,就会丢失互动元素的可互动性、布局也无法改变。当一个学生回头看录播的时候无法进行参与,只能作为旁观者看到别的同学的互动过程。这也是互动小班课第一个难点——互动元素如何处理?如何进行录制?回放的时候如何保持同步?实际中是有很多坑点和挑战。</p><h2>5.关于自研</h2><p><img src="/img/remote/1460000041414977" alt="在这里插入图片描述" title="在这里插入图片描述"></p><p>最后想和大家探讨一些关于自研实时通信系统的问题。</p><p>这里的部分内容截取自 ToB 厂商对痛点的分析,自研所遇到的问题可以分为以下几点:</p><ol><li>成本:除了人力、资源覆盖、动态扩缩容的运维等,还有与之对应的机会成本。前两点都比较重要。另外不同业务带宽峰值位置不同,复用一套基础设施和带宽资源可以降低资源、能源的消耗。</li><li>风险:比如上文提到用一套MCU结合两套架构,可能会引入额外的风险。</li><li>边界:比如是否加入特殊配置解决业务问题,团队内做自研对于业务需求的边界如何把握的问题?</li><li>系统优化门槛:当跑通上文提到的所有内容后,业务可以跑起来。但如果想要进一步压缩成本,就需要对更深技术栈的理解,比如数据驱动的全链路传输优化,编解码的优化,难度和所需的人力可能都会更高。</li></ol><p><img src="/img/remote/1460000041414978" alt="在这里插入图片描述" title="在这里插入图片描述"></p><p><strong>但自研的优势也是很明显的:</strong></p><ol><li>对音视频基建的理解:音视频逐步成为一种基建,但如果团队只通过三方SDK的方式接入音视频能力可能无法深刻理解音视频技术的难点、无法正确评估风险、无法把握潜在的机会。</li><li>更多原子能力:自研技术可以根据复杂的业务需要按照业务线进行更灵活的配置,用合理的方式暴露更深的接口,这会让业务层获得更大的灵活性。</li><li>对产品、研发、技术支持提供帮助:音视频技术涉及广泛且复杂,让客户端研发同学、技术支持同学对业务出现的异常准确排错、根据埋点数据分析问题原因是很困难的。依赖音视频自研团队对业务中遇到的问题进行积累、理解更深层的原因、排查未来可能出现的隐患是一种行之有效的方法。通过音视频自研团队可以辅助产品进行设计、加速研发对音视频技术的落地,还能辅助技术支持在业务中确定用户问题原因、提早发现更深的隐患。毕竟再快的工单系统可能也无法比隔壁工位的支持来的更快。</li><li>成本控制、面向业务优化:当能操控的技术越底层,针对特定业务能做的优化空间也就越大,进一步优化体验的同时也有更多成本压缩的空间。</li></ol><p>感谢阅读,以上是本次的分享内容,谢谢!</p>
前端技术分享:页面性能优化问题复盘
https://segmentfault.com/a/1190000041412519
2022-02-16T11:10:19+08:00
2022-02-16T11:10:19+08:00
有道AI情报局
https://segmentfault.com/u/youdaotec
9
<h2>项目背景</h2><p>在 code_pc 项目中,前端需要使用 rrweb 对老师教学内容进行录制,学员可以进行录制回放。为减小录制文件体积,当前的录制策略是先录制一次全量快照,后续录制增量快照,录制阶段实际就是通过 MutationObserver 监听 DOM 元素变化,然后将一个个事件 push 到数组中。</p><p>为了进行持久化存储,可以将录制数据压缩后序列化为 JSON 文件。老师会将 JSON 文件放入课件包中,打成压缩包上传到教务系统中。学员回放时,前端会先下载压缩包,通过 JSZip 解压,取到 JSON 文件后,反序列化再解压后,得到原始的录制数据,再传入 rrwebPlayer 实现录制回放。</p><h2>发现问题</h2><p>在项目开发阶段,测试录制都不会太长,因此录制文件体积不大(在几百 kb),回放比较流畅。但随着项目进入测试阶段,模拟长时间上课场景的录制之后,发现录制文件变得很大,达到 10-20 M,QA 同学反映打开学员回放页面的时候,页面明显卡顿,卡顿时间在 20s 以上,在这段时间内,页面交互事件没有任何响应。</p><p>页面性能是影响用户体验的主要因素,对于如此长时间的页面卡顿,用户显然是无法接受的。</p><h2>问题排查</h2><p>经过组内沟通后得知,<strong>可能导致页面卡顿的主要有两方面因素</strong>:前端解压 zip 包,和录制回放文件加载。同事怀疑主要是 zip 包解压的问题,同时希望我尝试将解压过程放到 worker 线程中进行。那么是否确实如同事所说,前端解压 zip 包导致页面卡顿呢?</p><h3>3.1 解决 Vue 递归复杂对象引起的耗时问题</h3><p>对于页面卡顿问题,首先想到肯定是线程阻塞引起的,这就需要排查哪里出现长任务。</p><p>所谓长任务是指执行耗时在 50ms 以上的任务,大家知道 Chrome 浏览器页面渲染和 V8 引擎用的是一个线程,如果 JS 脚本执行耗时太长,就会阻塞渲染线程,进而导致页面卡顿。</p><p>对于 JS 执行耗时分析,这块大家应该都知道使用 performance 面板。在 performance 面板中,通过看火焰图分析 call stack 和执行耗时。火焰图中每一个方块的宽度代表执行耗时,方块叠加的高度代表调用栈的深度。</p><p>按照这个思路,我们来看下分析的结果:<br><img src="/img/remote/1460000041412521" alt="在这里插入图片描述" title="在这里插入图片描述"><br>可以看到,replayRRweb 显然是一个长任务,耗时接近 18s ,严重阻塞了主线程。</p><p>而 replayRRweb 耗时过长又是因为内部两个调用引起的,分别是左边浅绿色部分和右边深绿色部分。我们来看下调用栈,看看哪里哪里耗时比较严重:<br><img src="/img/remote/1460000041412522" alt="在这里插入图片描述" title="在这里插入图片描述"><br>熟悉 Vue 源码的同学可能已经看出来了,上面这些耗时比较严重的方法,都是 Vue 内部递归响应式的方法(右边显示这些方法来自 vue.runtime.esm.js)。</p><p>为什么这些方法会长时间占用主线程呢?在 Vue 性能优化中有一条:<strong>不要将复杂对象丢到 data 里面</strong>,否则会 Vue 会深度遍历对象中的属性添加 getter、setter(即使这些数据不需要用于视图渲染),进而导致性能问题。</p><p>那么在业务代码中是否有这样的问题呢?我们找到了一段<strong>非常可疑的代码</strong>:</p><pre><code class="typescript">export default {
data() {
return {
rrWebplayer: null
}
},
mounted() {
bus.$on("setRrwebEvents", (eventPromise) => {
eventPromise.then((res) => {
this.replayRRweb(JSON.parse(res));
})
})
},
methods: {
replayRRweb(eventsRes) {
this.rrWebplayer = new rrwebPlayer({
target: document.getElementById('replayer'),
props: {
events: eventsRes,
unpackFn: unpack,
// ...
}
})
}
}
}</code></pre><p>在上面的代码中,创建了一个 rrwebPlayer 实例,并赋值给 rrWebplayer 的响应式数据。在创建实例的时候,还接受了一个 eventsRes 数组,这个数组非常大,包含几万条数据。</p><p>这种情况下,如果 Vue 对 rrWebplayer 进行递归响应式,想必非常耗时。因此,我们需要将 rrWebplayer 变为 Non-reactive data(避免 Vue 递归响应式)。</p><p>转为 Non-reactive data,<strong>主要有三种方法</strong>:</p><p>数据没有预先定义在 data 选项中,而是在组件实例 created 之后再动态定义 this.rrwebPlayer (没有事先进行依赖收集,不会递归响应式);</p><p>数据预先定义在 data 选项中,但是后续修改状态的时候,对象经过 Object.freeze 处理(让 Vue 忽略该对象的响应式处理);</p><p>数据定义在组件实例之外,以模块私有变量形式定义(这种方式要注意内存泄漏问题,Vue 不会在组件卸载的时候销毁状态);</p><p>这里我们使用<strong>第三种方法</strong>,将 rrWebplayer 改成 Non-reactive data 试一下:</p><pre><code class="typescript">let rrWebplayer = null;export default {
//...
methods: {
replayRRweb(eventsRes) {
rrWebplayer = new rrwebPlayer({
target: document.getElementById('replayer'),
props: {
events: eventsRes,
unpackFn: unpack,
// ...
}
})
}
}
}</code></pre><p>重新加载页面,可以看到这时候页面虽然还卡顿,但是卡顿时间明显缩短到5秒内了。观察火焰图可知,replayRRweb 调用栈下,递归响应式的调用栈已经消失不见了:<br><img src="/img/remote/1460000041412523" alt="在这里插入图片描述" title="在这里插入图片描述"></p><h3>3.2 使用时间分片解决回放文件加载耗时问题</h3><p>但是对于用户来说,这样仍然是不可接受的,我们继续看一下哪里耗时严重:<br><img src="/img/remote/1460000041412524" alt="图片" title="图片"><br>可以看到问题还是出在 replayRRweb 这个函数里面,到底是哪一步呢:<br><img src="/img/remote/1460000041412525" alt="在这里插入图片描述" title="在这里插入图片描述"><br>那么 unpack 耗时的问题怎么解决呢?</p><p>由于 rrweb 录制回放 需要进行 dom 操作,必须在主线程运行,不能使用 worker 线程(获取不到 dom API)。对于主线程中的长任务,很容易想到的就是通过 时间分片,将长任务分割成一个个小任务,通过事件循环进行任务调度,在主线程空闲且当前帧有空闲时间的时候,执行任务,否则就渲染下一帧。方案确定了,下面就是选择哪个 API 和怎么分割任务的问题。</p><blockquote><p>这里有同学可能会提出疑问,为什么 unpack 过程不能放到 worker 线程执行,worker<br>线程中对数据解压之后返回给主线程加载并回放,这样不就可以实现非阻塞了吗?</p><p>如果仔细想一想,当 worker 线程中进行 unpack,主线程必须等待,直到数据解压完成才能进行回放,这跟直接在主线程中 unpack<br>没有本质区别。worker 线程只有在有若干并行任务需要执行的时候,才具有性能优势。</p></blockquote><p>提到时间分片,很多同学可能都会想到 requestIdleCallback 这个 API。requestIdleCallback 可以在浏览器渲染一帧的空闲时间执行任务,从而不阻塞页面渲染、UI 交互事件等。目的是为了解决当任务需要长时间占用主进程,导致更高优先级任务(如动画或事件任务),无法及时响应,而带来的页面丢帧(卡死)情况。因此,<strong>requestIdleCallback 的定位是处理不重要且不紧急的任务。</strong></p><blockquote>requestIdleCallback 不是每一帧结束都会执行,只有在一帧的 16.6ms<br>中渲染任务结束且还有剩余时间,才会执行。这种情况下,下一帧需要在 requestIdleCallback 执行结束才能继续渲染,所以<br>requestIdleCallback 每个 Tick 执行不要超过<br>30ms,如果长时间不将控制权交还给浏览器,会影响下一帧的渲染,导致页面出现卡顿和事件响应不及时。</blockquote><p><strong>requestIdleCallback 参数说明:</strong></p><pre><code class="typescript">// 接受回调任务
type RequestIdleCallback = (cb: (deadline: Deadline) => void, options?: Options) => number
// 回调函数接受的参数
type Deadline = {
timeRemaining: () => number // 当前剩余的可用时间。即该帧剩余时间。
didTimeout: boolean // 是否超时。
}</code></pre><p>我们可以用 requestIdleCallback 写个简单的 demo:</p><pre><code class="typescript">// 一万个任务,这里使用 ES2021 数值分隔符
const unit = 10_000;
// 单个任务需要处理如下
const onOneUnit = () => {
for (let i = 0; i <= 500_000; i++) {}
}
// 每个任务预留执行时间
1msconst FREE_TIME = 1;
// 执行到第几个任务
let _u = 0;
function cb(deadline) {
// 当任务还没有被处理完 & 一帧还有的空闲时间 > 1ms
while (_u < unit && deadline.timeRemaining() >FREE_TIME) {
onOneUnit();
_u ++;
}
// 任务干完
if (_u >= unit) return;
// 任务没完成, 继续等空闲执行
window.requestIdleCallback(cb)
}
window.requestIdleCallback(cb)</code></pre><p>这样看来 requestIdleCallback 似乎很完美,能否直接用在实际业务场景中呢?答案是不行。我们查阅 MDN 文档就可以发现,requestIdleCallback 还只是一个实验性 API,浏览器兼容性一般:<br><img src="/img/remote/1460000041412526" alt="在这里插入图片描述" title="在这里插入图片描述"><br>查阅 caniuse 也得到类似的结论,所有 IE 浏览器不支持,safari 默认情况下不启用:<br><img src="/img/remote/1460000041412527" alt="在这里插入图片描述" title="在这里插入图片描述"><br>而且还有一个问题,requestIdleCallback 触发频率不稳定,受很多因素影响。经过实际测试,FPS 只有 20ms 左右,正常情况下渲染一帧时长控制在16.67ms 。</p><p>为了解决上述问题,在 React Fiber 架构中,内部自行实现了一套 requestIdleCallback 机制:</p><ul><li>使用 requestAnimationFrame 获取渲染某一帧的开始时间,进而计算出当前帧到期时间点;</li><li>使用 performance.now() 实现微秒级高精度时间戳,用于计算当前帧剩余时间;</li><li>使用 MessageChannel 零延迟宏任务实现任务调度,如使用 setTimeout() 则有一个最小的时间阈值,一般是 4ms;</li></ul><p>按照上述思路,我们可以简单实现一个 requestIdleCallback 如下:</p><pre><code class="typescript">// 当前帧到期时间点
let deadlineTime;
// 回调任务
let callback;
// 使用宏任务进行任务调度
const channel = new MessageChannel();
const port1 = channel.port1;
const port2 = channel.port2;
// 接收并执行宏任务
port2.onmessage = () => {
// 判断当前帧是否还有空闲,即返回的是剩下的时间
const timeRemaining = () => deadlineTime - performance.now();
const _timeRemain = timeRemaining();
// 有空闲时间 且 有回调任务
if (_timeRemain > 0 && callback) {
const deadline = {
timeRemaining,
didTimeout: _timeRemain < 0,
};
// 执行回调
callback(deadline);
}
};
window.requestIdleCallback = function (cb) {
requestAnimationFrame((rafTime) => {
// 结束时间点 = 开始时间点 + 一帧用时16.667ms
deadlineTime = rafTime + 16.667;
// 保存任务
callback = cb;
// 发送个宏任务
port1.postMessage(null);
});
};</code></pre><p>在项目中,考虑到 api fallback 方案、以及支持取消任务功能(上面的代码比较简单,仅仅只有添加任务功能,无法取消任务),最终选用 React 官方源码实现。</p><p>那么 API 的问题解决了,剩下就是怎么分割任务的问题。</p><p>查阅 rrweb 文档得知,rrWebplayer 实例上提供一个 addEvent 方法,用于动态添加回放数据,可用于实时直播等场景。按照这个思路,我们可以将录制回放数据进行分片,分多次调用 addEvent 添加。</p><pre><code class="typescript">import {
requestHostCallback, cancelHostCallback,
}
from "@/utils/SchedulerHostConfig";
export default {
// ...
methods: {
replayRRweb(eventsRes = []) {
const PACKAGE_SIZE = 100;
// 分片大小
const LEN = eventsRes.length;
// 录制回放数据总条数
const SLICE_NUM = Math.ceil(LEN / PACKAGE_SIZE);
// 分片数量
rrWebplayer = new rrwebPlayer({
target: document.getElementById("replayer"),
props: {
// 预加载分片
events: eventsRes.slice(0, PACKAGE_SIZE),
unpackFn: unpack,
},
});
// 如有任务先取消之前的任务
cancelHostCallback();
const cb = () => {
// 执行到第几个任务
let _u = 1;
return () => {
// 每一次执行的任务
// 注意数组的 forEach 没办法从中间某个位置开始遍历
for (let j = _u * PACKAGE_SIZE; j < (_u + 1) * PACKAGE_SIZE; j++) {
if (j >= LEN) break;
rrWebplayer.addEvent(eventsRes[j]);
}
_u++;
// 返回任务是否完成
return _u < SLICE_NUM;
};
};
requestHostCallback(cb(), () => {
// 加载完毕回调
});
},
},
};</code></pre><p>注意最后加载完毕回调,源码中不提供这个功能,是本人自行修改源码加上的。</p><p>按照上面的方案,我们重新加载学员回放页面看看,现在已经基本察觉不到卡顿了。我们找一个 20M 大文件加载,观察下火焰图可知,录制文件加载任务已经被分割为一条条很细的小任务,每个任务执行的时间在 10-20ms 左右,已经不会明显阻塞主线程了:<br><img src="/img/remote/1460000041412528" alt="在这里插入图片描述" title="在这里插入图片描述"><br>优化后,页面仍有卡顿,这是因为我们拆分任务的粒度是 100 条,这种情况下加载录制回放仍有压力,我们观察 fps 只有十几,会有卡顿感。我们继续将粒度调整到 10 条,这时候页面加载明显流畅了,基本上 fps 能达到 50 以上,但录制回放加载的总时间略微变长了。使用时间分片方式可以避免页面卡死,但是录制回放的加载平均还需要几秒钟时间,部分大文件可能需要十秒左右,我们在这种耗时任务处理的时候加一个 loading 效果,以防用户在录制文件加载完成之前就开始播放。</p><p>有同学可能会问,既然都加 loading 了,为什么还要时间分片呢?假如不进行时间分片,由于 JS 脚本一直占用主线程,阻塞 UI 线程,这个 loading 动画是不会展示的,只有通过时间分片的方式,把主线程让出来,才能让一些优先级更高的任务(例如 UI 渲染、页面交互事件)执行,这样 loading 动画就有机会展示了。</p><h2>进一步优化</h2><p>使用时间分片并不是没有缺点,正如上面提到的,录制回放加载的总时间略微变长了。但是好在 10-20M 录制文件只出现在测试场景中,老师实际上课录制的文件都在 10M 以下,经过测试录制回放可以在 2s 左右就加载完毕,学员不会等待很久。</p><p>假如后续录制文件很大,需要怎么优化呢?之前提到的 unpack 过程,我们没有放到 worker 线程执行,这是因为考虑到放在 worker 线程,主线程还得等待 worker 线程执行完毕,跟放在主线程执行没有区别。但是受到时间分片启发,我们可以将 unpack 的任务也进行分片处理,然后根据 navigator.hardwareConcurrency 这个 API,开启多线程(线程数等于用户 CPU 逻辑内核数),以并行的方式执行 unpack ,由于利用多核 CPU 性能,应该能够显著提升录制文件加载速率。</p><h2>总结</h2><p>这篇文章中,我们通过 performance 面板的火焰图分析了调用栈和执行耗时,进而排查出<strong>两个引起性能问题的因素</strong>:Vue 复杂对象递归响应式,和录制回放文件加载。</p><p>对于 Vue 复杂对象递归响应式引起的耗时问题,本文提出的解决方案是,将该对象转为非响应式数据。对于录制回放文件加载引起的耗时问题,本文提出的方案是使用时间分片。</p><p>由于 requestIdleCallback API 的兼容性及触发频率不稳定问题,本文参考了 React 17 源码分析了如何实现 requestIdleCallback 调度,并最终采用 React 源码实现了时间分片。经过实际测试,优化前页面卡顿 20s 左右,优化后已经察觉不到卡顿,fps 能达到 50 以上。但是使用时间分片之后,录制文件加载时间略微变长了。后续的优化方向是将 unpack 过程进行分片,开启多线程,以并行方式执行 unpack,充分利用多核 CPU 性能。</p><blockquote><p>参考</p><p><strong>·</strong> vue-9-perf-secrets</p><p><strong>·</strong> React Fiber很难?六个问题助你理解</p><p><strong>·</strong> requestIdleCallback - MDN</p><p><strong>·</strong> requestIdleCallback - caniuse</p><p><strong>·</strong> 实现React requestIdleCallback调度能力</p><p>详情可点击<a href="https://link.segmentfault.com/?enc=7MqqUZ%2B3hIgKN53ae%2FVQlA%3D%3D.P1HBcIxHmPtjKsKLe4Y0nXHZTP9iZ8LPkoGgDbXleAQaYLW%2BHiMrUiJfMywklW%2FP1mB%2BV5xjaRXNVJkbc3f0AWLTrMGDBSpsmvIFJoy9Y79sS8AaCvlOmmyR11OCy1pPkw62LJEBGfg1pKaPFbC3qg%3D%3D" rel="nofollow">这里</a>查看</p></blockquote>
有道技术团队入选 2021思否中国技术先锋年度评选两项榜单
https://segmentfault.com/a/1190000041316881
2022-01-20T19:48:28+08:00
2022-01-20T19:48:28+08:00
有道AI情报局
https://segmentfault.com/u/youdaotec
1
<blockquote>2022 年 1 月 13 日,中国技术先锋年度评选 | 2021 SegmentFault<br>思否技术先锋年度榜单正式发布。网易有道技术团队同时登榜<strong>思否年度技术团队榜单</strong>和<strong>中国技术品牌影响力企业</strong>。</blockquote><h2>思否年度技术团队榜单</h2><p>2022年1月13日,SegmentFault 思否作为中国领先的新一代开发者社区,根据<strong>社区用户行为大数据</strong>(如文章 & 问答发布数量、获得声望 & 点赞量等)综合分析,评选出了 30 个<strong>最杰出的年度技术团队。</strong></p><p>本次最终评选出 30 支年度技术团队,<strong>有道技术团队</strong>入选,登上思否2021中国技术先锋年度榜单,荣获思否<strong>年度技术团队称号</strong>。<br><img src="/img/remote/1460000041316883" alt="在这里插入图片描述" title="在这里插入图片描述"></p><h2>中国技术品牌影响力企业</h2><p>2021 <strong>中国技术品牌影响力企业</strong>隶属于中国技术先锋年度评选,旨在挖掘并鼓励行业先锋企业和具有影响力的技术品牌。在重重评选下,网易凭借<strong>网易有道技术团队、网易易盾、网易云信和网易云音乐前端技术团队</strong>等登上中国技术先锋年度榜单,荣获2021中国技术品牌影响力企业称号。<br>2021 年,网易有道技术团队始终保持着高效产出,分享各类产品经验,并曾多次组织或参与<strong>顶级技术大会、开发者大赛、技术沙龙</strong>等活动,共输出<strong>高质量技术博客60+</strong> 篇。<br><img src="/img/remote/1460000041316884" alt="在这里插入图片描述" title="在这里插入图片描述"><br>作为一家以<strong>成就学习者“高效学习”</strong>为使命的智能学习公司,网易有道一直秉持着<strong>高效学习从有道开始</strong>的理念,时刻注重智能科技产品更新迭代,致力于让<strong>每一位学习者不断地提升自己</strong>的同时,还能收获<strong>最好的产品体验</strong>。<br>网易有道依托于强大的<strong>互联网AI等技术手段</strong>,围绕学习场景,打造一系列深受用户喜欢的学习产品和服务。包括<strong>有道词典、有道词典笔、有道智能学习终端等</strong>多样化智能学习工具,以及素质教育、学科教育和成人终身教育等<strong>覆盖全年龄段</strong>的<strong>在线课程平台</strong>,真正做到了从用户角度出发,以用户体验为先。<br>未来,网易有道技术团队将继续加强<strong>科技生态建设</strong>,以领先技术打造更多<strong>高质量科技产品</strong>,保持<strong>高质量技术内容输出</strong>,和开发者共同成长。</p>
DevOps峰会 | 研发效能实践助力互联网行业项目管理“行之有效”
https://segmentfault.com/a/1190000041285702
2022-01-14T17:48:20+08:00
2022-01-14T17:48:20+08:00
有道AI情报局
https://segmentfault.com/u/youdaotec
2
<blockquote><p><strong>摘要</strong></p><p>本文为网易有道企业发展高级效能项目经理张浩然《研发效能实践助力互联网行业项目管理“行之有效”》的演讲内容,围绕研发效能的实践和项目管理两个主题展开。</p></blockquote><p>我写分享PPT的时候,起初想的是针对于互联网行业的项目管理。但现在不止是互联网,传统行业也在做数字化转型。所以,这个项目管理是全行业都可以一起探讨的。我之前做研发,后面主要做项目管理,过程中做过一段时间的产品管理。目前主要在网易有道企业发展部,做整个研发效能的推广和项目管理的提升。</p><p>我将从四个部分来阐述下自身的分享主题。</p><h2>1.互联网项目管理面临的常见挑战</h2><p>目前我们处于一个乌卡时代,不光是互联网,传统行业也处于这个时代。任何一个时代到来,都不会抛弃任何一个人,<strong>这个时代有哪些特殊性?——易变,不确定性,复杂性和模糊性。</strong></p><p>所谓易变,就是每次迭代开发要快,因为市场变化很快,我们其实也不确定产品的下一步方向在哪,用户有哪些新的诉求;</p><p>而复杂性是说现在产品的形态、架构很多,不能像以前一样用一套标准的解决方案满足各个客户的需求。因为客户也学聪明了,我们需要去应对一些更复杂且不明确的诉求。模糊性更好理解,一些大厂如阿里是电商出身,百度是搜索引擎,但是越往后,每一块垂直领域市场趋于饱和,大家在各自的边界越来越模糊。所以,我们要不断地让我们的MVP产品快速推出,在乌卡时代取得先机。<br><img src="/img/remote/1460000041285704" alt="图片" title="图片"><br>项目管理,能在这个时代帮助企业降本增效,快速验证。但是我们在管理整个软件研发团队时,多少会遇到一些问题或困扰。比如现在有一个想法需要做,我们组建了一个团队,快速地做研发迭代。为了赶进度,大家还要加班。很辛苦,但真正交付产品的时候,还是会出现延期交付。</p><p>在研发过程中,为了赶进度多少会产生一些质量问题。在整个过程中,为了弥补延期和质量差的问题,会去抓人效,抓的过程中又容易引起团队里的一些反驳,导致士气低落,如果在上线以后,我们的产品再不被市场接受,整个团队久而久之士气更加低落。</p><p>有人说我是一个经验很丰富的项目管理或者产品经理,或者技术领导,我在整个排期或者任务分配的过程中都没有问题。那我们如果有跨团队协作,比如说网易有一个A团队,很重视测试环节,在测试环节分为好几轮,并且用网易易测平台去扫描检查代码。另外一个B团队,业务形态没有那么复杂,只是简单地走完测试流程就OK了,过程中没有很多自动化工具,可能还在用XMIND等思维导图写用例,线下做管理。但如果B团队的伙伴去支援A团队的项目,对于A团队的工具、流程都不熟悉,怎么能够快速让B团队融入A团队,快速产出,这都会成为阻碍团队高效产出的问题障碍。<br><img src="/img/bVcXorC" alt="image.png" title="image.png"><br>我们现在的挑战是会遇到交付过程慢的问题。因为我们的产品线越多,需要交付的产品就越多。在不同的过程中,针对于某一个产品有一些新的需求,需求过来以后就会建立很多版本分支。这个过程中,比如我正在开发A版本的过程插入了新需求,要新开B版本,从A切到B的时候其实是对开发有影响的。同时,比如项目前期,大家都在做一些调研,然后再开发,这个阶段测试人员在等待开发完成才能进入测试,等待时间过于长,导致整个交付比较慢。质量差也是一个问题,研发之前没有测试的意识,只写好自己的代码就提交了,可能有些为了赶进度都不自测。排期不合理,大家有可能经常会遇到这种问题。排期就是把研发的时间排出来,把剩下的时间压缩为测试时间,测试很大的一部分功能模块也就几天,三四天,小功能的话半天就得赶紧测完要上线,所以为了去赶进度,牺牲了质量。</p><p>协作难这个问题,有多个团队缺少标准的流程工具。在整个过程中一是出现人员的协作、项目切换难;二是遇到一些问题的话,可能会出现扯皮;最后,就是少度量。如果没有度量的话肯定是不行的,项目经理包括管理层不知道整个团队目前的现状,包括技术领导也肯定没有办法合理地做任务分配。整个过程度量的缺失,就没有办法去衡量大家的绩效。现在在推行OKR,其实也是有度量的,也就是KR的达成程度怎么样。这些常见问题时压在团队身上的几座大山。</p><p>在这个过程中,会形成马太效应。每次质量差,上线以后有问题,运维工程师变成“背锅侠”;测试需要针对现有的问题尽快去验证,追着开发去修复,成了“追责侠”;最后我们的开发,只能加班加点去干,成了“加班侠”。这就是互联网时代“三侠”,工作很累,但是结果不理想,每天还吵得不得了<br><img src="/img/bVcXorG" alt="image.png" title="image.png"></p><h2>2.针对这种情况,有道怎么做探索和实践?</h2><p><strong>第一步,发现问题。</strong></p><p>通过访谈或者观察的方式看目前是什么问题。访谈是一部分,观察也一定要有。因为整个研发团队在工作过程中是不希望被打扰的,如果打扰到他,一是可能配合力度不够,二是在访谈过程中可能会有一些答案是得不到的,所以在团队人员时间紧张的条件下,需要通过观察来发现问题。大家如果有时间,可以邀请去做共创,或者是内部的共创。同时可以把好的案例分享给我们团队,看看他们的接受程度。</p><p><strong>第二步,收集到一些问题,对问题做进一步的分析和定位。</strong></p><p>我们当时收集到了从需求到整个开发构建、测试、部署包括监控等多个环节的问题。</p><p>需求的话,一开始没有需求管理工具,大家靠书面记录,效率比较低,而且需求更新后,下游是不可知的。没有做需求变更管理就会导致快上线验收的时候才发现,开发的一些功能和实际想要的完全不一样,或者差异很大,这对团队是一个致命打击。包括开发构建、测试等环节也或多或少会有问题,针对这些问题我们做分析和定位,包括人、流程、工具各个方面。</p><p>针对这些,我们定了一个计划:</p><p>首先从业务到运维多个环节对单点的工具、流程进行一些优化,终极目标是建立一套能够从业务需求到上线发布的端到端,能够支持整个研发过程,快速高质量和持续发布的解决方案。</p><p><strong>一是要快速,二是质量要高,三是持续交付。不能是一次快,或者是一次质量好,要持续达到这个目标。</strong></p><p><strong>第一步,引入敏捷开发模式</strong><br><img src="/img/bVcXorJ" alt="image.png" title="image.png"><br><img src="/img/bVcXorM" alt="image.png" title="image.png"><br><strong>第二步,从整个企业发展的层面去打造一套工具链,把全局拉通</strong><br><img src="/img/bVcXorN" alt="image.png" title="image.png"><br><strong>第三步,准备自建自己的DevOps能效平台,做一体化平台。</strong></p><p>前期会通过一些流程规范,针对需求做基于流程的驱动。我们搭建了一套标准工作流,在这个工作流中基于不同的团队建分支流程,尽量落在这套标准的流程上。我们希望通过一些流程,来推动整个产研的过程,不管是通过卡片还是通过自动化的工具,或者说转化过程,通过工具去驱动,把这个数据沉淀下来。同时,在代码级别和测试级别,自己做了一些封装,有些是自己去做的。CICD方面后面讲,没有完全做成自建的,会做前端的封装。我们的产研团队的用户看到用的是一套平台,在一套平台上完全整个需求到上线的过程,对他们的感知比较友好。<br>CI/CD的核心流程,用tekton引擎去做的。通过一些pipeline组合,去完成整个流程管理,通过task的文件去做整个pod的调度,实现整个过程持续的流水线。<br><img src="/img/bVcXosl" alt="image.png" title="image.png"><br>基于这个层面,已经从产品设计到需求、代码去做一些构建,发到对应的环境,去做自动化的测试,完全去实现全链路的拉通,包括上线以后有数据看板。有了这一套全链路的工具平台,就可以更好地去支撑我们的敏捷开发模式,最后一步卡在运维。</p><p>这样的话,通过这套平台可以得到一个需求,跑完以后快速上线,上线以后快速地验证。验证完以后,是否OK,具不具备发布条件,当期发布目前已经是OK的。<br><img src="/img/bVcXoso" alt="image.png" title="image.png"></p><h2>3.项目管理与研发效能的有效结合</h2><p>把整个研发过程拉通,通过自建去做。我们的项目管理应该怎么去做一些切入?和研发效能做结合。首先,在项目管理方式上做一些改进,在传统的项目管理中有一个传统铁三角的概念。项目管理管理上,会针对项目的范围、时间、成本传统的软件行业,更注重这三块。比如我是乙方,给甲方签了合同,交付一些平台或者系统,在这个过程中会有严格的范围定位。我们的立项时间分几个月去交付,一期、二期、三期包括我们的时间、成本,甲乙方对接的时候更需要做成本控制。项目管理更多的是在这三个层面触发,然后在过程中去做过程管理。但是这种模式,其实是需要做一些改进的。因为现在是一个互联网时代,乌卡时代,大家不可能说在一开始就确定要什么,给你多长时间,给你多少预算做出来,所以需要做一个转变,这就是敏捷三角的概念.<br><img src="/img/bVcXosq" alt="image.png" title="image.png"><br>范围、时间和成本这三块也要看,只是比重没有那么高,而是作为整个项目管理的约束条件,也就是说<strong>我们要在有限的范围、时间、成本里面寻求最有价值的部分</strong>。</p><p>而在这个过程中,可以通过一些工具、流程把质量逐步提升。通过敏捷三角的项目管理方式,每次去交付最有价值,而不是全量的范围,可以基于价值每次去拆分最小的单元颗粒度做mvp,如果价值有变化,快速地做调整。</p><p>其次,项目管理过程中要做一些流程改进。从项目管理角度来说,原来是一个线性的流程,一般有了项目以后,要去做立项,做可研论证,可行性研究,做整个产品的设计,UE、UI的设计,研发、测试、上线,整个过程中都是线型的。存在一个问题,如果产品的需求PRD没有画好,UE是不是不能做?开发是不是也不能做?</p><p><strong>于是我们改进了流程,减少了它的前置时间。</strong>PRD没有完全写好,但是用这种用户故事的形式,先给出整个用户故事的全貌。通过最终要做成什么样,拆出来最有价值的部分,后面UE和开发就可以先做这个部分的需求。最有价值的部分做完以后,可以实现优先级排序里第二有价值的功能。相当于把整个功能拆解完以后,后续的各个流程都可以直接接入。<br><img src="/img/bVcXoss" alt="image.png" title="image.png"><br>另外根据项目管理计划,要做管理肯定要有一个度量体系,不然无法知道整个研发团队的效率点,或者工作饱和的情况。允许大家存在一定的“摸鱼”,但是要适可而止。有一些“摸鱼"明显不合理时,就需要做度量。</p><p>最后,项目管理里,我们也逐步找寻健康的度量方式和体系,逐步改进过程。</p><p>短期度量是第一步上来要做的,因为没有全流程的链路平台统计,都是基于过程的度量,只能单点收集需求、开发、测试、运维各个过程的数据。项目经理比较累,需要通过excel表,手动地去各个平台去扒,或者去其他的一些地方去问,这是比较难的。但是有了这个度量体系,基本上可以以此驱动。哪个数据现在大家看得比较重要,或者收集过程比较痛苦,就可以逐步做一些改进。</p><p>单点的数据有了,第二步就要关注交付的目标。比如这次需要交付5个还是交付10个的故事,这个过程基于整体的目标去做,在过程中会通过一些人员的效率,比如说周期,人均的效率包括缺陷的修复时间,通过整个迭代去看,而不是通过某个需求去看。另外,迭代发布的话,看一下发布频率或者发布前置时间。发布前可能涉及到环节没有准备好,因为遇到过一些情况,大家一开始着急开发,等到快上线的时候,给测试发申请,或者又在等一个机器配置什么的,很阻碍时间。发布时间就会拉得很长,导致最终整个迭代的版本上线就会很长。质量是会通过一些整个过程和迭代里面的冒烟驳回率、缺陷密度和缺陷漏测率和其他指标去做。</p><p>第三步,基于价值的度量。有了目标,但是这个目标只是说实现产品价值中间的过程,最终还是要实现有价值。以价值为导向,层层去过滤整个环节,单点去做还是通过迭代去做,层层拆解,逐步的消除。以价值的交付为导向,通过可视化的看法或者仪表盘逐步分解,分析,提高整个研发效能的效率和质量。<br><img src="/img/bVcXost" alt="image.png" title="image.png"><br>这样的结合路径会基于产品用户和市场三个层面做整体权衡,输出这次最有价值的点,相当于有一套自己的价值评估体系,会融入到整个产研体系。在产研过程中会去做一些需求规划、版本管理、时间盒,项目经理可以更有效地做过程把控。</p><p>我们的目标是希望通过整套能效平台,可以快速地探索和分析业务,形成一个MVP,快速实现MVP的上线和验证。</p><h2>4.研发效能的未来展望</h2><p><strong>一、DevOps继续深入,重点还是整合丰富的数据源。</strong></p><p>要关注数据是否有效,合理性怎么样,够不够丰富,要做这块的数据源整合,通过分析增加监控指标。这个深度需要更加合理,更加促进研发团队高效工作,不破坏团队氛围。</p><p><strong>二、现在已经启动了,但还没有形成很大的规模的AIOps。</strong></p><p>虽然我们公司也有AI的能力,AI的团队,但是AI要基于大数据去做。只有数据很丰富同时合理性高时,去做AIOps才会合理有效。因为只有对正确合理的数据进行分析,检测出的问题以及自动修复才是合理有意义的。用不合理的数据去做AI,方向就错了。</p><p><strong>三、终极的目标是想做NoOps,相当于释放整个产研侧的压力,完全实现流水线自动化。</strong></p><p>今天我的演讲就到这里,谢谢大家!<img src="/img/remote/1460000041285705" alt="在这里插入图片描述" title="在这里插入图片描述"></p>
有道围棋 AI:智能匹配儿童棋力的良师益友
https://segmentfault.com/a/1190000041257457
2022-01-10T14:30:53+08:00
2022-01-10T14:30:53+08:00
有道AI情报局
https://segmentfault.com/u/youdaotec
0
<h2>1 背景</h2><p>有道纵横是网易有道旗下专为4-8岁孩子量身打造的在线少儿围棋产品,于2019年启动,自研了全国首部在线交互式围棋动漫课程,从孩子的理解力和喜好出发,采用直播互动的课程形式将围棋知识变得简单有趣、易懂好学,帮助孩子掌握围棋的各类规则和技巧。不仅如此,课后还设有AI对弈功能,能够智能识别孩子的段位水平匹配对局练习,从根源培养孩子的思维习惯。每局对弈结束后的智能分析,会从大局观、计算力、稳定性、战斗和棋型五方面进行全方位分析,帮助孩子在复盘中进步。</p><p>Google旗下Deepmind提出的AlphaGo、AlphaGo Zero、AlphaZero系列算法展示了深度强化学习在棋类领域超凡的能力。2016年AlphaGo横空出世击败欧洲围棋冠军樊麾二段,2017年以4:1击败韩国围棋职业九段,14个世界冠军得主李世石,2018年无师自通的AlphaGo Zero以3:0击败最年轻的六冠王柯洁九段。至此以后再无人质疑AI在围棋领域的霸主地位,同时引发了职业棋手学习AI招法的热潮。在职业围棋赛场上,时常出现“狗招”,学习、研究AI招法的背后的逻辑,已是职业棋手的必修课。</p><h2>2 现有AI技术存在的问题</h2><p>Github上已经有了Leela Zero、KataGo等基于AlphaZero系列算法的优秀围棋AI开源项目,它们的主要目标是提升AI的棋力,目前上述围棋AI的棋力已远超人类职业棋手。然而当强AI应用在少儿围棋教学时,出现了“水土不服”的现象,比如:<br>• AI实在是太强了,人很难在与AI对弈的过程中体会到“旗鼓相当”的感觉,这极易引起用户的挫败感。<br>• 授人以鱼而未授人以渔,AI只告诉人应该这么下,而不教会人为什么这么下。<br>• AI的学习路径与人大相径庭,一些在人早期围棋学习阶段就可以掌握的知识(如征子),AI在训练后期才掌握。</p><h2>3 有道围棋AI团队的成果</h2><p>有道围棋AI团队隶属于有道人工智能语音组,负责有道纵横产品与围棋AI相关的研发、落地工作,主要发力点在于AI的人机对弈和复盘。现有的工作成果引用一段CEO周枫的话:</p><blockquote><strong>有道纵横做到了什么?</strong><br>总体上有道纵横是一个面向孩子的围棋启蒙课程,大班直播、名师教学,在边学边练过程中有丰富的互动,同时也具备AI对弈能力。与此同时,有道纵横将教、学、练、测、评五个环节做了非常好的整合,形成了这个产品的全貌。<br>这里面有一个大家也会非常关心的问题,即AI老师到底有没有用?<br>技术团队永远都说AI老师特别有用,可以解决个性化教学的问题,可以因材施教;老师背景的团队往往觉得AI老师就是洪水猛兽,既没有用而且骗了很多VC的钱。<br>AI老师到底有没有用?<br>纵横项目当中做了比较多的AI老师的思考和实践。我们看法是,大众对于AI的认知,其实对于产品团队来说是个双刃剑,只有认识到双刃剑的作用才能做出正确的设计。<br>什么是双刃剑?一方面AI是一个非常好的营销抓手;另外一方面,用户不懂做产品,团队必须去自己寻找真正的AI价值点。如果你听用户对哪个东西兴奋就做哪个,最后往往掉坑里了。<br>在AI场景下,我们思考了非常久。首先想到AlphaGo,不管多牛都下得过你,但这么和用户讲显然不可能,所以本身对弈的难度和棋力不是教学当中AI的指标,而是如何降低难度,怎么能够灵活的调整难度。<br>所以,第一,我们团队花了大量功夫做难度可控的、棋力可控的围棋AI;第二,可控棋力的AI和复盘能力;第三,我们推的是学员和学员、学员和老师之间的对弈,强调人人对弈而不是人机对弈,人机对弈只是找不到人对弈时候的补充手段。<br>通过这样的手段,我们实现了自主研发的围棋AI,教学过程当中能够代替掉人的部分工作,提高了团队的生产效率。</blockquote><p><img src="/img/remote/1460000041257459" alt="在这里插入图片描述" title="在这里插入图片描述"></p><h2>4 解决方案及思路</h2><h3>4.1 人机对弈</h3><p>一个理想的人机对弈教学系统具有如下特点:<br>• AI的招法有逻辑性,用户很难感受到是AI在下棋。<br>• 合理控制AI的水平,避免一边倒的局面。<br>• AI可以配合教学进度,帮助用户巩固教学内容(如定式)。</p><p>一些其他方案在实现人机对弈系统时,一般使用AI训练过程早期的模型,然后使用模型的top-n输出,随机抽样进行落子行为,避免AI落子过于单一。</p><p>这种方案除了易于想到之外没有其他优点,由于早期模型训练量不大,采用top-n的采样方法会导致AI的招式没有条理,用户很容易诱导出这种落子逻辑的漏洞(如征子)。其次,在对弈过程中,AI模型和落子策略是固定的,但我们在实践中发现,AI对于围棋中的布局、中盘、收官等阶段的招法学习速度并不相同,AI对布局的掌握速度远远超出中盘、收官,使用相同的模型和策略会导致AI在整盘棋的表现差异极大。再者,AI的自对弈训练中,没有定式的概念(定式是围棋高手在某些局部的经验总结,用户学习定式走法可以快速提升棋力),低水平的AI很难在局部中下出最优解,而人可以通过学习高手的棋谱快速掌握局部最佳下法,即使人的水平并没有达到提出该定式的围棋高手水平。<strong>上述问题的根源在于AI与人的学习路径大相径庭,难以直接移植。</strong></p><p>考虑到以上问题后,围棋AI团队做了以下工作:<br>• 弃用top-n随机抽样的落子策略,使用AI引擎的policy输出,按概率采样。保证了AI招法逻辑性、连贯性。<br>• 在不同手数阶段,结合胜率和目差信息,调用不用的AI模型。保证AI在不同阶段的水平表现相近。<br>• 结合教学内容,实现AI模型和定式模板的混合输出。巩固用户学到的定式知识。<br><img src="/img/remote/1460000041257460" alt="在这里插入图片描述" title="在这里插入图片描述"></p><h3>4.2 复盘</h3><p>复盘指对局完毕后,复演该盘棋的记录,以检查对局中招法的优劣与得失关键。一般用以自学,或请高手给予指导分析。下围棋的高手都有复盘的习惯。复盘就是每次博弈结束以后,双方棋手把刚才的对局再重复一遍,这样可以有效地加深对这盘对弈的印象,也可以找出双方攻守的漏洞,是提高自己水平的好方法。在有道纵横产品中,AI承担了复盘老师的角色。</p><p>一些其他方案中,AI复盘主要是展示整局棋的胜率或目差曲线、AI的推荐变化图、以及一些基础的统计数据,这些内容更适合专业的用户,专业用户的需求在于快速定位自己下的不好的棋,然后根据AI提供的变化图等推理AI的落子逻辑,此类用户仅根据围棋AI引擎的原始数据就可以完成自我学习。</p><pre><code> 但是当用户群体定位到少儿时,上述的解决方案效果就会大打折扣,少儿用户很难理解统计数据背后的意义,同时对AI提供的变化图的逻辑缺乏分析能力,甚至注意力很难集中在变化图上,仅关注整局棋的胜率、目差的变化。此外,其他方案采用的复盘使用的GPU资源消耗很大,有的用户甚至需要半天时间才能拿到对局的复盘结果。
考虑到以上问题后,围棋AI团队做了以下工作:</code></pre><p>• 引入语音组的TTS技术,将复盘结果翻译成少儿用户易于接受的文案,提升用户的注意力。<br>• 性能优化,在少儿用户的使用场景中,用户并不需要高算力AI产生的复盘结果,我们指定了根据局面的复杂程度分配算力的方案。<br>• 结合用户以往的复盘记录,刻画用户的围棋水平,形成长期的学情报告。<br><img src="/img/remote/1460000041257461" alt="在这里插入图片描述" title="在这里插入图片描述"></p><h2>5 总结与展望</h2><p>目前围棋AI的技术主要集中于提升AI水平上,这固然为专业用户自我训练提供了极大的便利,但由于高水平AI背后的行棋逻辑较为高深,当围棋AI为少儿用户提供服务时,少儿用户很难直接从高水平AI获取知识。<br>接下来我们希望可以在人机对弈场景中,为用户提供水平更合适、逻辑更连贯的AI陪练;在复盘场景中,为用户提供更清晰易懂的复盘报告。</p>
网易有道 | REDIS 云原生实战
https://segmentfault.com/a/1190000041184345
2021-12-27T11:03:19+08:00
2021-12-27T11:03:19+08:00
有道AI情报局
https://segmentfault.com/u/youdaotec
4
<p>REDIS 云原生实战</p><blockquote><p><strong>摘要</strong><br>本次以Redis为范例,阐述了有道基础架构团队在基础设施容器化道路上的实践,主要将从声明式管理,Operator工作原理,容器编排,主从模式,集群模式,高可用策略,集群扩缩容等方面展开。</p><p><strong>目录</strong></p><ul><li>背景</li><li>面临的挑战</li><li>声明式管理</li><li><p>Operator工作原理</p><ul><li>容器编排</li><li>主从模式</li></ul><p>• 主从拓扑图 <br>• 调和原理</p></li><li>集群模式<br>• 集群拓扑图 <br>• 调和原理</li><li>高可用策略 <br>• Kubernetes保证的高可用 <br>• Redis集群的高可用</li><li>监控观测</li><li>集群扩缩容</li><li>总结与展望</li></ul></blockquote><h2>背景</h2><p>Redis 是业务系统中较为常用的缓存服务,常用于流量高峰、数据分析、积分排序等场景,并且通过中间件可以实现系统之间的解耦,提升系统的可扩展性。</p><p>传统物理机部署中间件,需要运维人员手动搭建,启动时间较长,也不利于后期维护,无法满足业务快速发展的需求。</p><p>云原生相较于传统IT,可以助力业务平滑迁移、快速开发、稳定运维,大幅降低技术成本,节约硬件资源。</p><p>云原生中间件是指依托容器化、服务网格、微服务、Serverless等技术,构建可扩展的基础设施,持续交付用于生产系统的基础软件,在功能不变的前提下,提高了应用的可用性与稳定性。</p><p>在这种大趋势下,有道基础架构团队开始了云原生中间件的实践,除了本文介绍的 Redis,还包括 Elasticsearch、ZooKeeper 等。</p><h2>面临的挑战</h2><p>利用云原生技术可以解决当前Redis部署缓慢,资源利用率低等问题,同时容器化 Redis 集群也面临着一些挑战:</p><blockquote>• Kubernetes 如何部署 Redis 有状态服务<br>• 容器 Crash 后如何不影响服务可用性;<br> • 容器重启后如何保证Redis 内存中的数据不丢; <br> • 节点水平扩容时如何做到 slots 迁移时不影响业务;<br> • pod ip变化后集群的状态如何处理。</blockquote><h2>声明式管理</h2><p>对于一个 Redis 集群,我们的期望是能够 7x24 小时无间断提供服务,遇故障可自行修复。这与Kubernetes API的声明式特点如出一辙。</p><p>所谓“声明式”, 指的就是我们只需要提交一个定义好的 API 对象来“声明”我所期望的状态是什么样子,Kubernetes中的资源对象可在无外界干扰的情况下,完成当前状态到期望状态的转换,这个过程就是Reconcile过程。例如,我们通过yaml创建了一个Deployment ,Kubernetes将“自动的”根据yaml中的配置,为其创建好Pod,并拉取指定存储卷进行挂载,以及其他一系列复杂要求。</p><p>因此,我们的Redis集群是否可以使用一个类似的服务去完成这个过程呢?即我们需要定义这样的对象,定义服务Reconcile的过程。Kubernetes的Operator刚好可以满足这个需求,可以简单的理解Operator由资源定义和资源控制器构成,在充分解读集群和Operator的关系后,我们将整体架构图设计如下<br><img src="/img/remote/1460000041184347" alt="在这里插入图片描述" title="在这里插入图片描述"><br>Operator集群本身采用Deployment部署,由ETCD 完成选主,上层与Kubernetes的Api Server 、Controller Manager等组件进行通信,下层持续调和Redis集群状态。</p><p>哨兵模式中Redis服务用一套哨兵集群,使用StatefulSet部署,持久化配置文件。Redis server也采用 StatefulSet部署, 哨兵模式的实例为一主多从。</p><p>集群模式中的每个分片使用StatefulSet部署,代理采用Deployment部署。原生Pod、StatefulSet、Service、调度策略等由Kubernetes本身负责。</p><p>Redis的资源定义在ETCD中存储一份即可,我们只需要预先提交自定义资源的 yaml配置。<strong>如下所示为创建三个副本的Redis主从集群</strong>:</p><pre><code>
apiVersion: Redis.io/v1beta1
kind: RedisCluster
metadata:
name: my-release
spec:
size: 3
imagePullPolicy: IfNotPresent
resources:
limits:
cpu: 1000m
memory: 1Gi
requests:
cpu: 1000m
memory: 1Gi
config:
maxclients: "10000"</code></pre><p>其中,kind定义使用的CR名称,size为副本数,resources定义资源配额,config对应Redis Server的config,该定义存储在Kubernetes的ETCD数据库中,后续的具体资源申请与使用由Operator的Controller完成。</p><h2>Operator工作原理</h2><p>Operator 是 Kubernetes 的扩展模式,由CRD、Controller构成。它利用定制资源管理特定应用及其组件,Operator 遵循 Kubernetes 的理念。</p><p>Operator 无需任何修改,即可从 Kubernetes 核心中获得许多内置的自动化功能,如使用 Kubernetes 自动化部署和运行工作负载, 甚至可以自动化 Kubernetes 自身。</p><p>Kubernetes 的 Operator 模式可在不修改 Kubernetes 自身的代码基础上,通过控制器关联到一个以上的定制资源,即可以扩展集群的行为。Operator 是 Kubernetes API 的客户端,核心功能是充当定制资源的控制器。<br><img src="/img/remote/1460000041184348" alt="在这里插入图片描述" title="在这里插入图片描述"></p><p>CRD: Custom Resource Definition, 在Kubernetes中一切皆是资源,资源就是CRD,用户自定义的Kubernetes资源是一个类型 ,比如默认自带的由Deployment,Pod ,Service等。</p><p>CR: Custom Resource 是实现CRD的具体实例。</p><p>用户创建一个CRD自定义资源,ApiServer把CRD转发给webhook,webhook 进行缺省值配置 验证配置和修改配置,webhook处理完成后的的配置会存入ETCD中 ,返回给用户是否创建成功信息。Controller 会监测到CRD,按照预先写的业务逻辑,处理这个CRD,比如创建Pod、处理新节点与旧集群关系等,保证运行的状态与期望的一致。</p><h2>容器编排</h2><p>Redis 集群在 Kubernetes 中的最小部署单位为 Pod,因此在架构设计之前,需预先考虑Redis特性、资源限制、部署形态、数据存储、状态维护等内容,为不同类型的Redis集群配置合适的部署方式。</p><p><strong>资源限制</strong>:</p><p>Kubernetes 采用 request 和 limit 两种限制类型来对资源进行分配。</p><blockquote>• request(资源需求):即运行Pod的节点必须满足运行Pod的最基本需求才能启动。<br>\<br>• limit(资源限制):即运行Pod期间,可能内存使用量会增加,那最多能使用多少内存,这就是资源限额。</blockquote><p>Redis 基本不会滥用 cpu,因此配置1-2个核即可。内存根据具体业务使用分配,考虑到部分场景下会fork较多的内存,例如 aof 频繁刷写,aof 重写过程中,Redis 主程序称依旧可以接收写操作,这时会采用 copy on write (写时复制)的方法操作内存数据,若业务使用特点为“写多读少”,那么刷写期间将产生大量的内存拷贝,从而导致 OOM,服务重启。</p><p>一个有效的解决方式为减少刷写次数,将刷写操作放在夜间低流量时段进行。减少刷写次数的方法为适当增加auto-aof-rewrite-min-size的大小,可配置使用内存的5倍甚至更大的最小刷写量;其次可以主动触发刷写,判断内存使用达到的配额两倍时进行刷写,实际部署时一般也会预留50%的内存防止OOM。</p><p><strong>部署的基本形态</strong>:</p><p>依据数据是否需要持久化或是否需要唯一标识区分服务为无状态和有状态的服务,Redis集群需要明确主从、分片标识,大部分场景也需要数据持久化,Kubernetes使用StatefulSet来满足这一类需求。StatefulSet的顺序部署、逆序自动滚动更新更能提高Redis集群的可用性。</p><p>具体的:</p><blockquote><p>• Redis Server 使用 StatefulSet 启动,为标识为{StatefulSetName}-0的Pod设置Master角色,给其他Pod设置为该Master的从节点。</p><p>• Proxy无需存储任何数据,使用Deployment部署,便于动态扩展。</p></blockquote><p><strong>配置文件</strong>:<br>Redis Server 启动时需要一些配置文件,里面涉及到用户名和密码,我们使用 Configmap 和 Secret 来存储的。Configmap 是 Kubernetes的Api 对象,常用于存储小于1MB的非机密键值对。而 Secret 可以用于存储包含敏感信息的密码、令牌、密钥等数据的对象。</p><p>两种资源均可以在 Pod 运行的时候通过 Volume 机制挂载到 Pod 内部。</p><p><strong>存储</strong>:<br>存储使用的是 PVC(PersistentVolumeClaim) 加 PV (Persistent Volumes),PV为Kubernetes集群中的资源,由存储类StorageClass来动态供应,PV支持多种访问模式:ReadWriteOnce、ReadOnlyMany 或 ReadWriteMany,通过PV定义存储资源,PVC申请使用该存储资源。另外通过根据存储的 StorageClass 字段 可抽象不同的存储后端,如Cephfs、Cephrbd、Openebs、LocalStorage 等。</p><h2>主从模式</h2><h3>主从拓扑图</h3><p>Redis容器化后建立的每个 CR 表示一个完整的Redis服务,具体的服务模式包括哨兵模式和集群模式两种,在进行容器化过程中,除覆盖裸服务器部署结构外,也对架构进行了一定程度的优化。</p><p><strong>原生哨兵模式</strong><br>原生哨兵模式为每套实例配一组哨兵。<br><img src="/img/remote/1460000041184349" alt="在这里插入图片描述" title="在这里插入图片描述"></p><p><strong>共用哨兵模式</strong><br>所有实例共用一组哨兵将进一步提高实例启动速度,并在一定程度上可提高硬件资源利用率,实测单组哨兵可轻松应对百规模的主从集群。<br><img src="/img/remote/1460000041184350" alt="在这里插入图片描述" title="在这里插入图片描述"></p><h3>调和原理</h3><p>Reconcile 实现持续监测并对主从集群进行修复的功能。<br><img src="/img/remote/1460000041184351" alt="在这里插入图片描述" title="在这里插入图片描述"></p><ol><li>检查是否按照预期启动了全部的Pod,比如创建3个Server,那么需要按照预期启动三个才能继续进行后面的操作。</li><li>检查Master的数量,确保该实例仅有一个主节点(数量为0主动选一个;数量大于1手动修复)。</li><li><p>检查哨兵:</p><p>(1)所有的哨兵是否监控了正确的Master;</p><p>(2)所有的哨兵均知道相同的Slave;</p><p>(3)再次检查哨兵的数量,确保哨兵均可用。</p></li><li>检查 Service,使Service 的Endpoints指向正确的Master。</li><li>检查Redis config是否有做修改,有则对所有节点重写config参数。</li></ol><h2>集群模式</h2><h3>集群拓扑图</h3><p><strong>Redis Cluster + Proxy模式</strong></p><p>通过在传统Redis Cluster架构中引入代理功能,实现动态路由分发,并基于Kubernetes原生动态扩缩容特性,更易应对突发流量,合理分配使用资源。</p><p>代理基础转发规则如下</p><blockquote><p>代理基础转发规则如下:</p><p>• 对于操作单个Key的命令,Proxy会根据Key所属的Slot(槽)将请求发送给所属的数据分片。</p><p>• 对于操作多个Key的命令,如果这些Key是储存在不同的数据分片,Proxy会将命令拆分成多个命令分别发送给对应的分片。</p></blockquote><p>服务部署前,也对代理的部分功能进行了补充,例如移除不可用节点等。</p><p><img src="/img/remote/1460000041184352" alt="在这里插入图片描述" title="在这里插入图片描述"></p><h3>调和原理</h3><p>reconcile实现持续监测并对Redis Cluster进行修复功能。<br><img src="/img/remote/1460000041184353" alt="在这里插入图片描述" title="在这里插入图片描述"></p><p><strong>确保集群健康的步骤</strong>:</p><ol><li>等待所有 Pod 状态变为 Ready 且每个节点相互识别后,Operator 会在每个 StatefulSet 的 Pod 中挑选一个作为 Master 节点,其余节点为该 Master 的 Slave。</li><li>获取实例集群所有Pod的ip、所有Pod的cluster info(包含nodeIP,主从关系等)。</li><li><p>进入恢复流程</p><p>(1)处理失败节点, 对部分节点重启后的无效ip、状态为noaddr的僵尸节点进行forget操作;</p><p>(2)处理不可信节点 (所有handshake状态的节点),发生于某一个节点被移除(由forget node触发),但试图加入集群时,即该Pod在Operator角度下存在,但实际集群节点并不需要该节点,处理方式为删掉这个Pod,并再次做forget操作直到Pod被删除。</p></li><li>任选一个节点,使用CLUSTER MEET给该节点加入所有已知节点。</li><li>为StatefulSet中的Pod建立主从关系,同时给其分配Slots。若当前Master数量同预期不一致,则对应扩缩容操作,具体见’集群扩缩容’的横向扩缩容小节。</li><li>检查Redis config是否有做修改,有则对所有节点重写config参数。</li></ol><p><strong>确保代理健康的步骤</strong>:</p><ol><li>获取所有Running状态代理的Pod ip。</li><li>从代理获取Redis Server信息,将集群信息同步到所有的代理上,代理中不存在的Server ip做移除操作。</li><li>若代理中无可用Redis Server, 表示被全部移除,则添加一个,代理可自动发现集群其他Redis节点。</li></ol><h2>高可用策略</h2><h3>Kubernetes保证的高可用</h3><p>(1) 容器部署保证高可用:</p><p>Redis部署最小资源对象为Pod,Pod是Kubernetes创建或部署的最小/最简单的基本单位。</p><p>当启动出错,例如出现“CrashLoopBackOff”时,Kubernetes将自动在该节点上重启该Pod,当出现物理节点故障时,Kubernetes将自动在其他节点上重新拉起一个。<br>Pod未出问题,但程序不可用时,依托于健康检查策略,Kubernetes也将重启该Redis节点。</p><p>(2) 滚动升级:</p><p>节点纵向扩容时,使用StatefulSet的滚动升级机制,Kubernetes将逆序重启更新每个Pod,提高了服务的可用性。</p><p>(3) 调度的高可用:</p><p>Kubernetes本身不处理Redis 多个Pod组建的集群之间的部署关系,但提供了部署策略,为保证特定场景下的高可用,如因物理节点导致所有Redis节点均宕机,CRD在设计中加入了亲和与反亲和字段。<br>默认使用 podAntiAffinity 做节点打散,如下所示实例instance1的所有 Pod 将被尽可能调度到不同的节点上。</p><pre><code>spec:
affinity:
podAntiAffinity:
preferredDuringSchedulingIgnoredDuringExecution:
- podAffinityTerm:
labelSelector:
matchLabels:
Redis.io/name: instance1
topologyKey: Kubernetes.io/hostname
weight: 1</code></pre><h3>Redis集群的高可用</h3><p>Redis 服务运行期间不可避免的出现各种特殊情况,如节点宕机、网络抖动等,如何持续监测这类故障并进行修复,实现 Redis 集群的高可用,也是 Operator 需解决的问题,下面以哨兵模式模式为例描述集群如何进行故障恢复。</p><p><strong>主节点宕机</strong>:因物理节点驱逐、节点重启、进程异常结束等导致的Redis主节点宕机情况,哨兵会进行切主操作,然后Kubernetes会在可用物理节点上重新拉起一个Pod。</p><p><strong>从节点宕机</strong>:哨兵模式的Redis集群未开启读写分离,从节点宕机对服务无影响,后续Kubernetes会重启拉起一个Pod,Operator会将该Pod设置为新主节点的从节点。</p><p><strong>集群全部节点宕机</strong>:发生概率极小,但基于持久化可将服务影响降至最低,集群恢复后可继续提供服务。</p><p><strong>节点网络故障</strong>:主从模式下配置了三个哨兵用于集群选主操作,哨兵集群的每一个节点会定时对 Redis 集群的所有节点发心跳包检测节点是否正常。如果一个节点在down-after-milliseconds时间内没有回复Sentinel节点的心跳包,则该Redis节点被该Sentinel节点主观下线。</p><p>当节点被一个 Sentinel 节点记为主观下线时,并不意味着该节点肯定故障了,还需要Sentinel集群的其他Sentinel节点共同判断为主观下线才行。</p><p>该 Sentinel 节点会询问其他Sentinel节点,如果 Sentinel 集群中超过 quorum 数量的 Sentinel 节点认为该 Redis 节点主观下线,则该 Redis 客观下线。</p><p>如果客观下线的 Redis 节点是从节点或者是Sentinel节点,则操作到此为止,没有后续的操作了;如果客观下线的Redis节点为主节点,则开始故障转移,从从节点中选举一个节点升级为主节点。</p><p>集群模式故障转移与上述类似,不过不需要哨兵干预,而是由节点之间通过PING/PONG实现。</p><h2>监控观测</h2><p>Redis 的监控采用经典的 Exporter+Promethus 的方案,Exporter 用于指标采集,数据存储在 Prometheus 或其他数据库中,最终 Grafana 前端将服务状态可视化。</p><h2>集群扩缩容</h2><p><strong>(1)纵向扩缩容</strong><br>纵向扩缩容主要指Pod的CPU、内存资源的调整,基于Kubernetes的特性,只需修改实例对应的spec字段,Operator的调和机制将持续监测参数变化,并对实例做出调整 。当修改cpu 、内存等参数时,Operator同步更新StatefulSet的limit、request信息,Kubernetes将逆序滚动更新Pod,滚动更新时,若停掉的是主节点,主节点的preStop功能会先通知哨兵或者集群进行数据保存,然后做主从切换操作,从而将服务的影响降至最低。更新后的主从关系建立以及哨兵monitor主节点功能也由Operator一并处理,全过程对客户端无感知。主从版、集群版在该场景下均支持秒级断闪。</p><p><strong>(2)横向扩缩容</strong><br>横向扩缩容主要指副本数或节点数的调整,得益于 Kubernetes 的声明式 API,可以通过更改声明的资源规模对集群进行无损弹性扩容和缩容。</p><p>Redis Server扩容操作时,主从版本中Operator将获取新节点ip, 新启动节点将在下一轮调和时触发slaveof 主节点操作,且同步过程中,哨兵不会将该节点选为主节点。集群版本中Operator将在同步节点信息后进行分片迁移,保证所有节点上的Slots尽可能均匀分布。</p><p>Redis Server缩容操作时,主从版本中Operator将逆序销毁Pod,销毁时会先询问哨兵,自己是否为主节点,若为主节点则进行先failover操作再退出。集群版本中Operator中会先进行分片迁移,再对该节点做删除操作。</p><p>代理的扩缩容,更易实现,根据流量波峰波谷规律,可手动定期在波峰到来时对 Proxy 进行扩容,波峰过后对 Proxy 进行缩容;也可根据HPA实现动态扩缩容,HPA也是Kubernetes的一种资源,可以依据Kubernetes 的Metrics API的数据,实现基于CPU使用率、内存使用率、流量的动态扩缩容。</p><h2>总结与展望</h2><p>本次以 Redis 为范例,阐述了有道基础架构团队在基础设施容器化道路上的实践,Redis上云后将大幅缩短集群部署时间,支持秒级部署、分钟级启动、启动后的集群支持秒级自愈,集群依托于哨兵和代理的特性,故障切换对用户无感知。</p><p>有道架构团队最终以云平台的形式提供中间件能力,用户无需关注基础设施的资源调度与运维,重点关注具体业务场景,助力业务增长。未来,将进一步围绕Redis实例动态扩缩容、故障分析诊断、在线迁移、混合部署等内容展开探索。</p><blockquote><p><strong>Redis 容器化后有哪些优势?</strong> </p><p>Kubernetes 是一个容器编排系统,可以自动化容器应用的部署、扩展和管理。Kubernetes 提供了一些基础特性:</p><p><strong>部署</strong>:部署更快,集群建立无需人工干预。容器部署后可保证每个的Redis节点服务正常,节点启动后将由Operator持续监测调和Redis集群状态,包括主从关系、集群关系、哨兵监控、故障转移等。</p><p><strong>资源隔离</strong>:如果所有服务都用同一个集群,修改了Redis集群配置的话,很可能会影响到其他的服务。但如果你是每个系统独立用一个Redis群的话,彼此之间互不影响,也不会出现某一个应用不小心把集群给打挂了,然后造成连锁反应的情况。</p><p><strong>故障恢复</strong>:</p><p>(1) 实例的重启:容器化后的健康检查可以实现服务自动重启功能;<br>(2) 网络故障:因宿主机网络故障带来的实例延迟高,哨兵可进行主从切换,而为了保证集群的健康,将由Operator负责同步集群信息。</p><p><strong>扩缩容</strong>:容器部署可根据limit和request限制实例的cpu和内存,也可以进行扩缩容操作,扩容后的故障恢复由Operator处理。</p><p><strong>节点调整</strong>:基于Operator对CRD资源的持续调和,可在Operator的Controller中为每个Redis实例进行状态维护,因此,节点调整后带来的主副关系建立、集群Slots迁移等均可自动完成。</p><p><strong>数据存储</strong>:容器化可挂载Cephfs、LocalStorage等多种存储卷。</p><p><strong>监控与维护</strong>:实例隔离后搭配Exporter、Prometheus等监控工具更容易发现问题。</p></blockquote><p>-- END --</p>
语音合成(TTS)技术在有道词典笔中的应用实践
https://segmentfault.com/a/1190000041148940
2021-12-20T16:45:38+08:00
2021-12-20T16:45:38+08:00
有道AI情报局
https://segmentfault.com/u/youdaotec
2
<h2>1. 背景介绍</h2><p><img src="/img/remote/1460000041148942" alt="请添加图片描述" title="请添加图片描述"><br>自 2017 年 10 月推出有道翻译蛋开始,网易有道已先后推出了二十余款智能学习硬件产品,包括有道翻译王、有道口袋打印机、有道超级词典、有道词典笔、有道听力宝等。</p><p>其中,<strong>有道词典笔开创了智能词典笔品类</strong>,连续两年获天猫、京东销量第一,并广受用户好评。<br><img src="/img/remote/1460000041148943" alt="请添加图片描述" title="请添加图片描述"></p><p>在近期有道词典笔的全新软件升级中(关联阅读:全新软件升级!真的很有料),有<strong>两个重要的优化</strong>,分别是:</p><blockquote><p><strong>发音接近真人,告别机械式</strong></p><p><strong>发音准确度提升,正确朗读多音词</strong></p></blockquote><h3>应用效果:</h3><p>首先,我们进一步升级了发音系统,使中英发音尽可能接近真人。</p><p>为了给用户带来更好的体验,有道 AI 团队选取了多种真人发音素材,从来自公司内部、真实用户和 native speakers 等人群中选取足够大的样本发放调查问卷,从<strong>发音准确度、音色喜爱度</strong>等方面进行打分,并和专业的发音进行比较,最终选取了目前版本中的音色。</p><blockquote><p><strong>一个彩蛋</strong></p><p>在我们选取的发音中,不乏一些<strong>明星语音</strong>,小伙伴们可以猜猜她是谁?</p><p><a href="https://link.segmentfault.com/?enc=VS5w%2Bs4u7aUZhUcBDFzbsw%3D%3D.IEoKhzm0v68nsTX0RDS4VK2eqt%2BKE1fAMxjYkQvNEZjAkNQ%2BNv9OwfDTKREC5y5MVWkxXMv4p3u5nnZNqkM4Bc8UfZ49NU6q%2F7QE9KNSuwU%3D" rel="nofollow">Who is she?(01)</a></p><p><a href="https://link.segmentfault.com/?enc=EUnTJuTkIjnTbzDKQMwSHg%3D%3D.VVRv%2F8gdaw7mGZUUnIkI55Cf6fUYTHwhI7VnO2P8GMP3l569R8uTmJsTSmIbsvVWDOihqujK2gv7mOhbgqMN7sGzSX9g7wkBvlxUUk%2FlWFk%3D" rel="nofollow">Who is she?(02)</a></p><p>(答案在文末揭晓)</p></blockquote><p>在语言学习场景中,机械式的发音不仅让人觉得枯燥乏味,而且会影响口语学习的效果。<strong>最自然、最理想的交互</strong>莫过于通过人的声音进行交流。如何让智能学习硬件的发音接近真人,是一个重要的课题。</p><blockquote><p>中文:</p><p><a href="https://link.segmentfault.com/?enc=qo6obtDOBP%2BkZLbWjPkKKA%3D%3D.Mf7ID8Gj0zdndAIwZ0wE%2Bp%2B01tQHaEcLssCk2%2FuV978TUzIRAXLIh82Jwfu2KpvEBRHjrXbtcq%2BAz5pwv4lmzDUYSgpe%2BD9lDGZWL94CBbc%3D" rel="nofollow">机械式发音 - 中文</a></p><p><a href="https://link.segmentfault.com/?enc=gwNTMUYmOEkn%2FOFMhWWbfw%3D%3D.5KNbuELOmorFm9KhJJy9IL%2B02ECXHLMNUtEVSHFi6FS00hU1Iqdj%2Fi7AkWGX1%2BW4dVXhgBglEMtC0oAwN2gWn7PiQJZXBC5ll2kh9O%2Bqiw0%3D" rel="nofollow">有道无限接近真人发音 - 中文</a></p><p>英文:</p><p><a href="https://link.segmentfault.com/?enc=HIIV0hJoUNnczbR6UcT5pw%3D%3D.8JmzgWcr1ghjeTHXancFyK1NCEauWCbvYjvLz0tianBkjoaFThixqAeVth7mzlXfeMRJMaESrOU8Dl3J3Kp3PF1tKchfP6HOVm9Ks8Qtg10%3D" rel="nofollow">机械式发音 - 英文</a></p><p><a href="https://link.segmentfault.com/?enc=tuG%2FsHTdKmnXICwdJ5s7tw%3D%3D.FkMqK%2B4qRiGwB6pKETxly7NshEa%2Fy4GqMC74x1KK6pJWESgNp17wuJ49Q0RZBEinA6GFBaXqRYFwnouRolgDuc0f2P%2BQDqjdZ%2FThp3u86yk%3D" rel="nofollow">有道无限接近真人发音 - 英文</a></p></blockquote><p>同时,通过有道 AI 团队对语言模型的不断训练,有道词典笔的发音准确度再一次得到突破,在扫描句子的过程中,有道词典笔可以快速预判语义,轻松读对一些英语学习者和 AI 都非常容易读错的单词,比如「<strong>多音词</strong>」。</p><p>以包含“read过去式”的句子为例,我们来听听有道词典笔的发音和传统机械式发音:</p><blockquote><p>She picked up the letter and read it. </p><p>她拿起信读了起来。</p></blockquote><p>在本句中,动词 read 是过去式,应读作/red/。</p><blockquote><p><a href="https://link.segmentfault.com/?enc=b0JYlqhuCyvWqsIMaCYOOw%3D%3D.HHgFFAx%2FViMZfNA680%2FaVLrAhpNpO4gLD%2BAN4Crjc7j0%2FIbQLbkhT3mKbScKu38A7CtWXOpxbS8%2BbiFLNAkpF1nEFqfhHT8dlNLh1LG8AQM%3D" rel="nofollow">传统方案 - 误读多音词</a></p><p><a href="https://link.segmentfault.com/?enc=46bh06SQw7kZyuM2c2bbzg%3D%3D.yLFcJA2FFXx%2BuHhzucWYiCaZLo6DjyCgiQpipO%2BMXs8M6UnONL9SUYpC1HDf9WbgaPZLt15Wtj8MPk%2BcMKr%2BlZc%2FosVTrGBkLnAEMbd7Wts%3D" rel="nofollow">有道 - 精准朗读多音词</a></p></blockquote><p>这些能力的背后,是<strong>有道 TTS 语音合成技术</strong>的加持。本文将会详细介绍有道 TTS 技术的相关思考和实践。</p><h2>2. 有道TTS语音合成技术</h2><p>有道 TTS 语音合成技术建模流程包括<strong>文本分析模块、声学模型模块和声码器模块</strong>。</p><h3>2.1 基于开源BERT多任务的统一TTS文本分析前端</h3><p>文本分析前端的主要作用是将语句转换为语言学特征,主要<strong>是音素序列</strong>和<strong>韵律特征</strong>, 其中音素序列决定 TTS 是否正确读对了文本;韵律特征决定 TTS 的停顿位置、自然度等,这也是有道 TTS 技术能够实现接近真人发音和正确朗读多音词的关键所在。</p><p>传统的文本分析模块会单独建模每个任务,并且串行处理效率较低,这种做法在嵌入式场景中难以实现性能和质量的平衡,多个任务分离也会提高系统的维护成本。</p><p>相比于传统方案,有道 AI 团队基于 BERT 预训练模型进行了多任务建模,将多个任务进行统一建模,大大提高了效率。<br><img src="/img/remote/1460000041148944" alt="请添加图片描述" title="请添加图片描述"></p><p>这些优化能够支持 TTS 前端的文本正则化、多音字判别、韵律预测等任务,使有道系统能够在设备端合成<strong>低发音错误、韵律自然和感情丰富</strong>的高质量语音。</p><p>有道词典笔场景 TTS 前端也面临一些<strong>挑战</strong>:</p><blockquote><ol><li>满足接近100%发音准确率要求;在中文、英文里,大量的多音字、多音词是影响发音准确率的关键所在,而且对于有道词典笔教育场景,古诗词、文言文正确读法也需要全量覆盖.</li><li>韵律特征建模,满足TTS合成停顿自然、语义清晰需求。</li><li>词典笔设备资源有限,在满足上述两点质量的同时,还需要达到性能需求。</li></ol></blockquote><p>基于这些问题,我们主要做了以下几个方面的工作,分别是<strong>资源收集、模型实验、系统集成</strong>:</p><blockquote><p><strong>资源收集</strong>:在资源收集阶段,借助有道独有教研资源,搜集整理多音字表,<br>结合词性、词义等细化多音字模型标签,使得建模更高效;在中文古诗词、文言文发音上,通过 ssml 技术将词典笔海量权威发音词典资源应用到TTS 发音中;</p><p><strong>模型实验</strong>:在模型实验阶段,前端包含有多音字、韵律预测、分词、词性预测等这些任务,<br>通过构建bert多任务模型,联合预测多音字、韵律、分词、词性任务,多个任务之互相促进不仅了提升多音字模型和韵律模型的准确率,同时也节省了参数量;最后通过蒸馏技术,小参数量多任务模型在保证质量的同时,也达到嵌入式性能要求;</p><p><strong>系统集成</strong>:在系统集成阶段,工程化团队通过自研bert pipeline技术,更进一步优化了内存和推理时间;</p></blockquote><p>通过这些方面的工作,最终推出了<strong>基于预训练模型的多任务架构 TTS 中英混前端</strong>,保证了 TTS 合成的发音正确性和韵律停顿。</p><h3>2.2 非自回归的 VAE 声学模型</h3><p>声学模型的主要作用是将语言学特征转换为对应的声学特征。常见的神经网络声学模型大致可以分成两大类:<br><strong>一是自回归声学模型</strong>:比如 Tacotron、Tacotron2,优点是高自然度,缺点是性能较差;基于 attention 的自回归声学模型难以建模长语音,更容易出现丢字、重复的现象。</p><p><strong>二是非自回归声学模型</strong>:比如Fastspeech、Fastspeech2,优点是并行生成声学特征,性能好,对长句建模足够鲁棒;缺点是韵律建模略差于自回归声学模型。</p><p>综合质量和性能,有道 AI 团队最终选择了<strong>基于 VAE 的非自回归声学模型</strong>。原因在于它有以下优势:</p><blockquote><p>在鲁棒性方面:好于 Tacotron2;</p><p>在性能方面:和 Fastspeech 一样快,快于 Tacotron2;</p><p>在质量方面:接近 Tacotron2,相比 Fastspeech 更易于训练。</p></blockquote><p>同时,我们针对<strong>一部分算子的计算耗时占总时长比例较大的</strong>问题进行了工程上的优化,进一步改善了系统整体的实时率。<br>另外,还对模型进行了量化,降低了模型的内存。</p><h3>2.3 基于 GAN 的声码器</h3><p>声码器的作用是将声学模型输出的声学特征转换成语音时域信号。它直接影响着合成语音的音质,因此对于用户体验来说至关重要。<br>在有道智能硬件产品实际落地开发中,声码器技术的研发面临着几大难点问题:</p><p><strong>一是音质问题</strong>。声码器模型的建模能力不足,会直接导致合成语音产生底噪或者电音。但如果仅仅只是单纯地加大模型的参数,则会影响系统的推理速度。</p><p><strong>二是性能问题</strong>。声码器的计算量在语音合成的整个框架中占比较大。要在嵌入式场景中合成高质量的语音,需要一个足够大、建模能力足够强的声码器模型。</p><p>但由于设备芯片的算力弱、内存小,大的声码器会导致体验延时明显上升。从用户的角度出发,延时过长,用户等待时间过久,自然不会有好的体验效果。</p><p>为了解决以上难题,通过大量实验和综合比对,最终有道 AI 团队选择了基于 GAN 方案的声码器。</p><p>任何学术上的方案要实现成工业界的产品,都需要进行大量的实验和打磨。</p><p>首先是针对不同场景使用不同的模型配置,有道 AI 团队对 GAN 声码器中的生成器模块进行了参数的细致调整,让它能够成功应用在嵌入式场景下,不同于传统参数声码器的机械感与模糊感,基于 GAN 的神经网络声码器可以合成高自然度、高清晰度的音频,<strong>缩短了离线 TTS 和在线 TTS 质量上的差距。</strong></p><p><img src="/img/remote/1460000041148945" alt="请添加图片描述" title="请添加图片描述"></p><p>此外,我们还在模型的量化、压缩方面做了大量的工作,大大提升了语音合成的速度,明显降低了系统的资源占用。</p><h2>3. 总结</h2><p>在智能硬件产品人机交互中,语音合成技术扮演着非常重要的角色,但在落地中面临着很多挑战,<strong>其核心是硬件计算资源与合成语音质量之间的矛盾。</strong></p><p>如何更快地、更稳定地在有限资源下提供高质量的语音合成技术是有道 AI 团队的目标和关注的重点。</p><p>目前,有道 TTS 语音合成技术已应用在许多内部和外部的在线场景和嵌入式场景,并表现出了相对传统方案更加稳定、更加鲁棒的合成效果。</p><p>-- END --</p><blockquote>彩蛋答案<br><img src="/img/remote/1460000041148946" alt="请添加图片描述" title="请添加图片描述"></blockquote><p><img src="/img/remote/1460000041148947" alt="请添加图片描述" title="请添加图片描述"></p>
上班没找到车位,硬核程序员做了套“园区车位实时推荐系统”,还获了奖
https://segmentfault.com/a/1190000040987095
2021-11-19T14:56:23+08:00
2021-11-19T14:56:23+08:00
有道AI情报局
https://segmentfault.com/u/youdaotec
2
<p>在一年一度的1024程序员节上,网易技术马拉松再次拉开帷幕。限时48小时,16支队伍,共84名技术热爱者展开了角逐。</p><p>与大多数项目和业务息息相关不同,有一只队伍的项目和实际生活更贴近。来自有道的“车位不用等”队,参赛课题为<strong>《园区车位实时推荐系统》</strong>,应用视觉识别、语音合成等技术,打造了一套停车解决方案。最终在本次技术马拉松当中,获得了<strong>技术极客优秀奖。</strong><br><img src="/img/bVcV8Hi" alt="720735edbac688cf8aaf6deaea25a9ca.jpg" title="720735edbac688cf8aaf6deaea25a9ca.jpg"></p><p>谈到为什么选择这样一个课题,队长Shinji表示,“有时候上班停车要花费不少时间,而且停车难也是目前很普遍的一个问题,所以想要用技术方式来设计一个解决方案。”</p><h2>一、项目分析</h2><p>有车的小伙伴应该都有过被停车支配的恐惧,无论是在商场、园区,明明显示还有空位,但绕了两圈也不知道在哪…好不容易找到了一个空位,然而正要开过去却被人抢了先…</p><p>总结起来,困难点就是两个——找车位和抢车位。</p><p>那么,如何利用技术来解决这两个问题呢?<br><img src="/img/bVcV8Hr" alt="8880a487211432cd118ccf00f2c0d209.jpg" title="8880a487211432cd118ccf00f2c0d209.jpg"></p><h4>整体的设计思路是:</h4><p>依托于视频车辆检测算法,达到对多个车位的实时监控,灵活适配有效区域并一定程度降低算法误识率。利用工作园区内车辆以及车主等有效信息为进场车辆合理分配停车区域,降低人工指挥停车的成本,并提升停车效率。</p><h2>二、解决方案</h2><h3>1.车位区域监控</h3><p>利用车辆识别算法对监控范围内的车辆进行监控,并将数据实时上报。</p><p>这种方式的施工难度小,可以监控多车位,并且可以筛选掉无效区域。不过比较依赖识别算法的准确度,对于摄像头的安装位置和角度有一定的要求。</p><p>车辆检测我们针对实际场景考虑了两种方案。</p><ul><li>方案一:基于深度学习和图像识别技术,采用边缘计算,摄像头内置算法进行车辆检测。</li><li>方案二:摄像头只做图像采集,每10秒上传到服务器。服务器再对数据做车辆检测的计算。</li></ul><p>方案一对比方案二的优势是摄像识别当前拍摄范围内车辆的数量和具体位置,数量发生变化的时候通知给服务器。边缘计算会大大的减少带宽以及服务器的计算压力。但带来的问题是这种摄像头的成本会比较高。</p><p>但无论方案一还是方案二摄像头的摆放都有一定的要求,一个摄像头要清晰覆盖到尽可能多的车位,理想情况是覆盖到10-12个车位,具体还是要结合实际情况。覆盖到足够多的车位的主要因素是降低硬件成本,即摄像头的成本。</p><p>基于公司已有摄像头,初期模型我们使用的是方案二。</p><h3>2.车位区域划分</h3><p>将停车场内所有车位按照位置、大小、监控区域等条件划分为若干区域。</p><blockquote>管理的最小粒度由某一车位扩大为一组车位,方便路线规划且降低管理难度。<br>派位时给予车主一定的选择权利,适配分派中的复杂需求且有利于推广。</blockquote><h3>3.车位推荐</h3><ul><li>车辆入场,道闸识别车牌或车型数据上报推荐服务。</li><li><p>推荐服务匹配车辆信息。</p><ul><li>匹配成功,对所有可停车区域按空闲车位数粗排,再根据匹配策略进行重排,最后根据分流策略确认推荐车位;</li><li>匹配失败,按默认最多空位规则确认推荐车位。</li></ul></li><li>对车位进行预占,将入区域预占池。过期后预占将失效。</li><li>将推荐车位通知道闸和APP,发送语音播报或通知,告知车主停车区域。</li></ul><p>推荐车位会考虑<strong>多个维度。</strong></p><ul><li>车位距离楼梯,电梯的位置,进一步楼梯、电梯和员工具体的工位位置。</li><li>根据已入场车辆的路线,推荐不是特别多的车辆的位置。</li><li>远离豪车华。</li><li>员工习惯停车位置。</li><li>新能源优先推荐新能源车辆可充电车位。</li><li>大车优先推荐大车位,减少小车使用大车位的几率。</li><li>进一步打通会议系统,若距离会议时间比较近,根据会议室的位置以及距离现有的空车位推荐最合理,可以快速停车的车位。尽可能的做到个性化,合理化。</li></ul><p><img src="/img/bVcV8Hw" alt="" title=""></p><h2>三、项目应用效果展示</h2><p><a href="https://www.bilibili.com/video/BV18Q4y1v7D7/?aid=719339220&cid=444998568&page=1">https://www.bilibili.com/vide...</a></p><h2>总结</h2><p>为了解决停车场景中找车位和抢车位的问题,我们将视觉识别技术和推荐算法引入停车管理项目。依托于视频车辆检测算法,达到对多个车位的实时监控,灵活适配有效区域并一定程度降低算法误识率。利用工作园区内车辆以及车主等有效信息为进场车辆合理分配停车区域,降低人工指挥停车的成本,并提升停车效率。</p><p>以派位的方式解决停车过程中的问题,结合路线规划还可以避免拥堵,后面如果有机会也会考虑加入项目规划。同时,未来希望扩展支持地上停车位的管理,并进一步降低项目实施的成本,摊薄算法检测成本并让单路摄像头可以有效监控更多的停车位,提高项目收益。</p>
技术干货 | Flutter在线编程实践总结
https://segmentfault.com/a/1190000040943076
2021-11-11T11:33:16+08:00
2021-11-11T11:33:16+08:00
有道AI情报局
https://segmentfault.com/u/youdaotec
3
<h2>1.Flutter架构</h2><p>Flutter的架构主要分成三层:Framework,Engine,Embedder。</p><p>1.Framework使用dart实现,包括Material Design风格的Widget,Cupertino(针对iOS)风格的Widgets,文本/图片/按钮等基础Widgets,渲染,动画,手势等。 此部分的核心代码是:flutter仓库下的flutter package,以及sky_engine仓库下的io,async,ui(dart:ui库提供了Flutter框架和引擎之间的接口)等package。</p><p>2.Engine使用C++实现,主要包括:Skia,Dart和Text。Skia是开源的二维图形库,提供了适用于多种软硬件平台的通用API。</p><p>3.Embedder是一个嵌入层,即把Flutter嵌入到各个平台上去,这里做的主要工作包括渲染Surface设置,线程设置,以及插件等。 从这里可以看出,Flutter的平台相关层很低,平台(如iOS)只是提供一个画布,剩余的所有渲染相关的逻辑都在Flutter内部,这就使得它具有了很好的跨端一致性。<br><img src="/img/remote/1460000040943078" alt="图1" title="图1"></p><h2>2.Flutter视图绘制</h2><p>对于开发者来说,使用最多的还是framework,我就从Flutter的入口函数开始一步步往下走,分析一下Flutter视图绘制的原理。</p><p>在Flutter应用中,main()函数最简单的实现如下</p><pre><code>// 参数app是一个widget,是Flutter应用启动后要展示的第一个Widget。
void runApp(Widget app) {
WidgetsFlutterBinding.ensureInitialized()
..scheduleAttachRootWidget(app)
..scheduleWarmUpFrame();
}</code></pre><h3>1.WidgetsFlutterBinding</h3><p>WidgetsFlutterBinding继承自BindingBase 并混入了很多Binding,查看这些 Binding的源码可以发现这些Binding中基本都是监听并处理Window对象(包含了当前设备和系统的一些信息以及Flutter Engine的一些回调)的一些事件,然后将这些事件按照Framework的模型包装、抽象然后分发。</p><p>WidgetsFlutterBinding正是粘连Flutter engine与上层Framework的“胶水”。</p><ol><li>GestureBinding:提供了window.onPointerDataPacket 回调,绑定Framework手势子系统,是Framework事件模型与底层事件的绑定入口。</li><li>ServicesBinding:提供了window.onPlatformMessage 回调, 用于绑定平台消息通道(message channel),主要处理原生和Flutter通信。</li><li>SchedulerBinding:提供了window.onBeginFrame和window.onDrawFrame回调,监听刷新事件,绑定Framework绘制调度子系统。</li><li>PaintingBinding:绑定绘制库,主要用于处理图片缓存。</li><li>SemanticsBinding:语义化层与Flutter engine的桥梁,主要是辅助功能的底层支持。</li><li>RendererBinding: 提供了window.onMetricsChanged 、window.onTextScaleFactorChanged 等回调。它是渲染树与Flutter engine的桥梁。</li><li>WidgetsBinding:提供了window.onLocaleChanged、onBuildScheduled 等回调。它是Flutter widget层与engine的桥梁。</li></ol><p>WidgetsFlutterBinding.ensureInitialized()负责初始化一个WidgetsBinding的全局单例,代码如下</p><pre><code>class WidgetsFlutterBinding extends BindingBase with GestureBinding, ServicesBinding, SchedulerBinding, PaintingBinding, SemanticsBinding, RendererBinding, WidgetsBinding {
static WidgetsBinding ensureInitialized() {
if (WidgetsBinding.instance == null)
WidgetsFlutterBinding();
return WidgetsBinding.instance;
}
}</code></pre><p>看到这个WidgetsFlutterBinding混入(with)很多的Binding,下面先看父类BindingBase:</p><pre><code>abstract class BindingBase {
...
ui.SingletonFlutterWindow get window => ui.window;//获取window实例
@protected
@mustCallSuper
void initInstances() {
assert(!_debugInitialized);
assert(() {
_debugInitialized = true;
return true;
}());
}
}</code></pre><p>看到有句代码Window get window => ui.window链接宿主操作系统的接口,也就是Flutter framework 链接宿主操作系统的接口。系统中有一个Window实例,可以从window属性来获取,看看源码:</p><pre><code>// window的类型是一个FlutterView,FlutterView里面有一个PlatformDispatcher属性
ui.SingletonFlutterWindow get window => ui.window;
// 初始化时把PlatformDispatcher.instance传入,完成初始化
ui.window = SingletonFlutterWindow._(0, PlatformDispatcher.instance);
// SingletonFlutterWindow的类结构
class SingletonFlutterWindow extends FlutterWindow {
...
// 实际上是给platformDispatcher.onBeginFrame赋值
FrameCallback? get onBeginFrame => platformDispatcher.onBeginFrame;
set onBeginFrame(FrameCallback? callback) {
platformDispatcher.onBeginFrame = callback;
}
VoidCallback? get onDrawFrame => platformDispatcher.onDrawFrame;
set onDrawFrame(VoidCallback? callback) {
platformDispatcher.onDrawFrame = callback;
}
// window.scheduleFrame实际上是调用platformDispatcher.scheduleFrame()
void scheduleFrame() => platformDispatcher.scheduleFrame();
...
}
class FlutterWindow extends FlutterView {
FlutterWindow._(this._windowId, this.platformDispatcher);
final Object _windowId;
// PD
@override
final PlatformDispatcher platformDispatcher;
@override
ViewConfiguration get viewConfiguration {
return platformDispatcher._viewConfigurations[_windowId]!;
}
}</code></pre><h3>2.scheduleAttachRootWidget</h3><p>scheduleAttachRootWidget紧接着会调用WidgetsBinding的attachRootWidget方法,该方法负责将根Widget添加到RenderView上,代码如下:</p><pre><code> void attachRootWidget(Widget rootWidget) {
final bool isBootstrapFrame = renderViewElement == null;
_readyToProduceFrames = true;
_renderViewElement = RenderObjectToWidgetAdapter<RenderBox>(
container: renderView,
debugShortDescription: '[root]',
child: rootWidget,
).attachToRenderTree(buildOwner!, renderViewElement as RenderObjectToWidgetElement<RenderBox>?);
if (isBootstrapFrame) {
SchedulerBinding.instance!.ensureVisualUpdate();
}
}</code></pre><p>renderView变量是一个RenderObject,它是渲染树的根。renderViewElement变量是renderView对应的Element对象。可见该方法主要完成了根widget到根 RenderObject再到根Element的整个关联过程。</p><pre><code>RenderView get renderView => _pipelineOwner.rootNode! as RenderView;</code></pre><p>renderView是RendererBinding中拿到PipelineOwner.rootNode,PipelineOwner在 Rendering Pipeline 中起到重要作用:</p><p>随着 UI 的变化而不断收集『 Dirty Render Objects 』随之驱动 Rendering Pipeline 刷新 UI。</p><p>简简单讲,PipelineOwner是『RenderObject Tree』与『RendererBinding』间的桥梁。</p><p>最终调用attachRootWidget,执行会调用RenderObjectToWidgetAdapter的attachToRenderTree方法,该方法负责创建根element,即RenderObjectToWidgetElement,并且将element与widget 进行关联,即创建出 widget树对应的element树。如果element 已经创建过了,则将根element 中关联的widget 设为新的,由此可以看出element 只会创建一次,后面会进行复用。BuildOwner是widget framework的管理类,它跟踪哪些widget需要重新构建。代码如下</p><pre><code>RenderObjectToWidgetElement<T> attachToRenderTree(BuildOwner owner, [RenderObjectToWidgetElement<T> element]) {
if (element == null) {
owner.lockState(() {
element = createElement();
assert(element != null);
element.assignOwner(owner);
});
owner.buildScope(element, () {
element.mount(null, null);
});
} else {
element._newWidget = this;
element.markNeedsBuild();
}
return element;
}
</code></pre><h3>3.scheduleWarmUpFrame</h3><p>runApp的实现中,当调用完attachRootWidget后,最后一行会调用 WidgetsFlutterBinding 实例的 scheduleWarmUpFrame() 方法,该方法的实现在SchedulerBinding 中,它被调用后会立即进行一次绘制(而不是等待"vsync" 信号),在此次绘制结束前,该方法会锁定事件分发,也就是说在本次绘制结束完成之前Flutter将不会响应各种事件,这可以保证在绘制过程中不会再触发新的重绘。</p><p>下面是scheduleWarmUpFrame() 方法的部分实现(省略了无关代码):</p><pre><code>void scheduleWarmUpFrame() {
...
Timer.run(() {
handleBeginFrame(null);
});
Timer.run(() {
handleDrawFrame();
resetEpoch();
});
// 锁定事件
lockEvents(() async {
await endOfFrame;
Timeline.finishSync();
});
...
}</code></pre><p>该方法中主要调用了handleBeginFrame() 和 handleDrawFrame() 两个方法</p><p>查看handleBeginFrame() 和 handleDrawFrame() 两个方法的源码,可以发现前者主要是执行了transientCallbacks队列,而后者执行了 persistentCallbacks 和 postFrameCallbacks 队列。</p><pre><code>1. transientCallbacks:用于存放一些临时回调,一般存放动画回调。
可以通过SchedulerBinding.instance.scheduleFrameCallback 添加回调。
2. persistentCallbacks:用于存放一些持久的回调,不能在此类回调中再请求新的绘制帧,持久回调一经注册则不能移除。
SchedulerBinding.instance.addPersitentFrameCallback(),这个回调中处理了布局与绘制工作。
3. postFrameCallbacks:在Frame结束时只会被调用一次,调用后会被系统移除,可由 SchedulerBinding.instance.addPostFrameCallback() 注册。
注意,不要在此类回调中再触发新的Frame,这可以会导致循环
</code></pre><p>真正的渲染和绘制逻辑在RendererBinding中实现,查看其源码,发现在其initInstances()方法中有如下代码:</p><pre><code>void initInstances() {
... // 省略无关代码
addPersistentFrameCallback(_handlePersistentFrameCallback);
}
void _handlePersistentFrameCallback(Duration timeStamp) {
drawFrame();
}
void drawFrame() {
assert(renderView != null);
pipelineOwner.flushLayout(); // 布局
pipelineOwner.flushCompositingBits(); //重绘之前的预处理操作,检查RenderObject是否需要重绘
pipelineOwner.flushPaint(); // 重绘
renderView.compositeFrame(); // 将需要绘制的比特数据发给GPU
pipelineOwner.flushSemantics(); // this also sends the semantics to the OS.
}</code></pre><p>需要注意的是:由于RendererBinding只是一个mixin,而with它的是WidgetsBinding,所以需要看看WidgetsBinding中是否重写该方法,查看WidgetsBinding的drawFrame()方法源码:</p><pre><code>@override
void drawFrame() {
...//省略无关代码
try {
if (renderViewElement != null)
buildOwner.buildScope(renderViewElement);
super.drawFrame(); //调用RendererBinding的drawFrame()方法
buildOwner.finalizeTree();
}
}</code></pre><p>在调用RendererBinding.drawFrame()方法前会调用 buildOwner.buildScope() (非首次绘制),该方法会将被标记为“dirty” 的 element 进行 rebuild() <br>我们再来看WidgetsBinding,在initInstances()方法中创建BuildOwner对象,然后执行<code>buildOwner!.onBuildScheduled = _handleBuildScheduled;</code>,这里将_handleBuildScheduled赋值给了buildOwnder的onBuildScheduled属性。</p><p>BuildOwner对象,它负责跟踪哪些widgets需要重新构建,并处理应用于widgets树的其他任务,其内部维护了一个_dirtyElements列表,用以保存被标“脏”的elements。</p><p>每一个element被新建时,其BuildOwner就被确定了。一个页面只有一个buildOwner对象,负责管理该页面所有的element。</p><pre><code>// WidgetsBinding
void initInstances() {
...
buildOwner!.onBuildScheduled = _handleBuildScheduled;
...
}());
}
当调用buildOwner.onBuildScheduled()时,便会走下面的流程。
// WidgetsBinding类
void _handleBuildScheduled() {
ensureVisualUpdate();
}
// SchedulerBinding类
void ensureVisualUpdate() {
switch (schedulerPhase) {
case SchedulerPhase.idle:
case SchedulerPhase.postFrameCallbacks:
scheduleFrame();
return;
case SchedulerPhase.transientCallbacks:
case SchedulerPhase.midFrameMicrotasks:
case SchedulerPhase.persistentCallbacks:
return;
}
}
当schedulerPhase处于idle状态,会调用scheduleFrame,然后经过window.scheduleFrame()中的performDispatcher.scheduleFrame()去注册一个VSync监听
void scheduleFrame() {
...
window.scheduleFrame();
...
}</code></pre><h3>4.小结</h3><p>Flutter从启动到显示图像在屏幕主要经过:首先监听处理window对象的事件,将这些事件处理包装为Framework模型进行分发,通过widget创建element树,接着通过scheduleWarmUpFrame进行渲染,接着通过Rendererbinding进行布局,绘制,最后通过调用ui.window.render(scene)Scene信息发给Flutter engine,Flutter engine最后调用渲染API把图像画在屏幕上。</p><p>我大致整理了一下Flutter视图绘制的时序图,如下<br><img src="/img/remote/1460000040943079" alt="图2" title="图2"></p><h2>3.Flutter性能监控</h2><p>在对视图绘制有一定的了解后后,思考一个问题,怎么在视图绘制的过程中去把控性能,优化性能,我们先来看一下Flutter官方提供给我们的两个性能监控工具</p><h3>1.Dart VM Service</h3><h4>1.observatory</h4><p>observatory: 在engine/shell/testings/observatory可以找到它的具体实现,它开启了一个ServiceClient,用于获取dartvm运行状态.flutter app启动的时候会生成一个当前的observatory服务器的地址</p><pre><code>flutter: socket connected in service Dart VM Service Protocol v3.44 listening on http://127.0.0.1:59378/8x9XRQIBhkU=/</code></pre><p><img src="/img/remote/1460000040943080" alt="图3" title="图3"><br> 比方说选择了timeline后,可以进行性能分析,如图<br><img src="/img/remote/1460000040943081" alt="图4" title="图4"></p><h4>2.devTools</h4><p>devTools也提供了一些基本的检测,具体的细节没有Observatory提供的完善. 可视性比较强</p><p>可以通过下面命令安装</p><pre><code>flutter pub global activate devtools</code></pre><p>安装完成后通过devtools命令打开,输入DartVM地址<br><img src="/img/remote/1460000040943082" alt="图5" title="图5"></p><p>打开后的页面</p><p><img src="/img/remote/1460000040943083" alt="图6" title="图6"></p><p>devtools中的timeline就是performance,我们选择之后页面如下,操作体验上好了很多<br><img src="/img/remote/1460000040943084" alt="图7" title="图7"><br>observatory与devtools都是通过vm_service实现的,网上使用指南比较多,这边就不多赘述了,我这边主要介绍一下Dart VM Service (后面 简称 vm_service)</p><p>是 Dart 虚拟机内部提供的一套 Web 服务,数据传输协议是 JSON-RPC 2.0。</p><p>不过我们并不需要要自己去实现数据请求解析,官方已经写好了一个可用的 Dart SDK 给我们用:<code>vm_service</code>。 vm_service 在启动的时候会在本地开启一个 WebSocket 服务,服务 URI 可以在对应的平台中获得:</p><p>1)Android 在 <code>FlutterJNI.getObservatoryUri()</code> 中;</p><p>2)iOS 在 <code>FlutterEngine.observatoryUrl</code> 中。</p><p>有了 URI 之后我们就可以使用 vm_service 的服务了,官方有一个帮我们写好的 SDK: <code>vm_service</code></p><pre><code> Future<void> connect() async {
ServiceProtocolInfo info = await Service.getInfo();
if (info.serverUri == null) {
print("service protocol url is null,start vm service fail");
return;
}
service = await getService(info);
print('socket connected in service $info');
vm = await service?.getVM();
List<IsolateRef>? isolates = vm?.isolates;
main = isolates?.firstWhere((ref) => ref.name?.contains('main') == true);
main ??= isolates?.first;
connected = true;
}
Future<VmService> getService(info) async {
Uri uri = convertToWebSocketUrl(serviceProtocolUrl: info.serverUri);
return await vmServiceConnectUri(uri.toString(), log: StdoutLog());
}</code></pre><p>获取frameworkVersion,调用一个VmService实例的callExtensionService,传入'flutterVersion',就能拿到当前的flutter framework和engine信息</p><pre><code> Future<Response?> callExtensionService(String method) async {
if (_extensionService == null && service != null && main != null) {
_extensionService = ExtensionService(service!, main!);
await _extensionService?.loadExtensionService();
}
return _extensionService!.callMethod(method);
}</code></pre><p><img src="/img/remote/1460000040943085" alt="图8" title="图8"></p><p>获取内存信息,调用一个VmService实例的getMemoryUsage,就能拿到当前的内存信息</p><pre><code> Future<MemoryUsage> getMemoryUsage(String isolateId) =>
_call('getMemoryUsage', {'isolateId': isolateId});</code></pre><p><img src="/img/remote/1460000040943086" alt="图9" title="图9"></p><p>获取 Flutter APP 的 FPS,官方提供了好几个办法来让我们在开发 Flutter app 的过程中可以使用查看 fps等性能数据,如devtools,具体见文档 Debugging Flutter apps 、Flutter performance profiling 等。</p><pre><code>// 需监听fps时注册
void start() {
SchedulerBinding.instance.addTimingsCallback(_onReportTimings);
}
// 不需监听时移除
void stop() {
SchedulerBinding.instance.removeTimingsCallback(_onReportTimings);
}
void _onReportTimings(List<FrameTiming> timings) {
// TODO
}</code></pre><h3>2.崩溃日志捕获上报</h3><p>flutter 的崩溃日志收集主要有两个方面:</p><p>1)flutter dart 代码的异常(包含app和framework代码两种情况,一般不会引起闪退,你猜为什么)</p><p>2)flutter engine 的崩溃日志(一般会闪退)</p><p>Dart 有一个 <code>Zone</code> 的概念,有点类似<code>sandbox</code>的意思。不同的 Zone 代码上下文是不同的互不影响,Zone 还可以创建新的子Zone。Zone 可以重新定义自己的<code>print</code>、<code>timers</code>、<code>microtasks</code>还有最关键的how uncaught errors are handled 未捕获异常的处理</p><pre><code>runZoned(() {
Future.error("asynchronous error");
}, onError: (dynamic e, StackTrace stack) {
reportError(e, stack);
});
</code></pre><h4>1.Flutter framework 异常捕获</h4><p>注册 <code>FlutterError.onError</code> 回调,用于收集 Flutter framework 外抛的异常。</p><pre><code>FlutterError.onError = (FlutterErrorDetails details) {
reportError(details.exception, details.stack);
};</code></pre><h4>2.Flutter engine 异常捕获</h4><p>flutter engine 部分的异常,以Android 为例,主要为 libfutter.so发生的错误。</p><p>这部份可以直接交给native崩溃收集sdk来处理,比如 firebase crashlytics、 bugly、xCrash 等等 </p><p>我们需要将 dart 异常及堆栈通过 MethodChannel传递给 bugly sdk 即可。 </p><p>收集到异常之后,需要查符号表(symbols)还原堆栈。</p><p>首先需要确认该 flutter engine 所属版本号,在命令行执行:</p><p>flutter --version<br>输出如下:</p><pre><code>Flutter 2.2.3 • channel stable • https://github.com/flutter/flutter.git
Framework • revision f4abaa0735 (4 months ago) • 2021-07-01 12:46:11 -0700
Engine • revision 241c87ad80
Tools • Dart 2.13.4</code></pre><p>可以看到 Engine 的 revision 为 241c87ad80。</p><p>其次,在 flutter infra 上找到对应cpu abi 的 symbols.zip 并下载,解压后,可以得到带有符号信息的 debug so 文件—— libflutter.so,然后按照平台文档上传进行堆栈还原就可以了,如bugly平台就提供了上传工具</p><p>java -jar buglySymbolAndroid.jar -i xxx</p><h2>4.Flutter性能优化</h2><p>在业务开发中我们要学会用devtools来检测工程性能,这样有助于我们实现健壮性更强的应用,在排查过程中,我发现视频详情页存在渲染耗时的问题,如图<br><img src="/img/remote/1460000040943087" alt="图10" title="图10"></p><h3>1.build耗时优化</h3><p>VideoControls控件的build耗时是28.6ms,如图</p><p><img src="/img/remote/1460000040943088" alt="图11" title="图11"><br>所以这里我们的优化方案是提高build效率,降低Widget tree遍历的出发点,将setState刷新数据尽量下发到底层节点,所以将VideoControl内触发刷新的子组件抽取成独立的Widget,setState下发到抽取出的Widget内部</p><p>优化后为11.0ms,整体的平均帧率也达到了了60fps,如图</p><p><img src="/img/remote/1460000040943090" alt="图12" title="图12"></p><h3>2.paint耗时优化</h3><p>接下来分析下paint过程有没有可以优化的部分,我们打开debugProfilePaintsEnabled变量分析可以看到Timeline显示的paint层级,如图<br><img src="/img/remote/1460000040943091" alt="图13" title="图13"></p><p>我们发现频繁更新的_buildPositionTitle和其他Widget在同一个layer中,这里我们想到的优化点是利用RepaintBoundary提高paint效率,它为经常发生显示变化的内容提供一个新的隔离layer,新的layer paint不会影响到其他layer</p><p>看下优化后的效果,如图</p><h3>3.小结</h3><p>在Flutter开发过程中,我们用devtools工具排查定位页面渲染问题时,主要有两点:</p><p><strong>1.提高build效率</strong>,setState刷新数据尽量下发到底层节点。</p><p><strong>2.提高paint效率</strong>,RepaintBoundry创建单独layer减少重绘区域。</p><p>当然 Flutter 中性能调优远不止这一种情况,build / layout / paint 每一个过程其实都有很多能够优化的细节。</p><h2>5.总结</h2><h3>1.回顾</h3><p>这篇文章主要从三个维度来介绍Flutter这门技术,分别为绘制原理讲解,我们review了一下源码,发现整个渲染过程就是一个闭环,Framework,Engine,Embedder各司其职,简单来说就是Embedder不断拿回Vsync信号,Framework将dart代码交给Engine翻译成跨平台代码,再通过Embedder回调宿主平台;性能监控就是不断得在这个循环中去插入我们的哨兵,观察整个生态,获取异常数据上报;性能优化通过一次项目实践,学习怎么用工具提升我们定位问题的效率。</p><h3>2.优缺点</h3><p>优点:</p><p>我们可以看到Flutter在视图绘制过程中形成了闭环,双端基本保持了一致性,所以我们的开发效率得到了极大的提升,性能监控和性能优化也比较方便。</p><p>缺点: </p><p>1)声明式开发 动态操作视图节点不是很友好,不能像原生那样命令式编程,或者像前端获取dom节点那般容易</p><p>2)实现动态化机制,目前没有比较好的开源技术可以去借鉴</p>
NEJ Build太慢怎么办?试试MOOC NEJ吧,只需两步,提升70%构建性能!
https://segmentfault.com/a/1190000040909111
2021-11-04T11:13:49+08:00
2021-11-04T11:13:49+08:00
有道AI情报局
https://segmentfault.com/u/youdaotec
0
<h2>NEJ Build太慢怎么办?试试MOOC NEJ吧,只需两步,提升70%构建性能!</h2><blockquote>由于历史包袱,中国大学MOOC(简称中M)的主站工程生产构建时间大约在21分钟,构建采用NEJ Build,由于NEJ当前已无人维护且在部门内应用较多,因此中M通过fork原工程在保留NEJ原有功能的前提下,将NEJ的核心打包流程进行改造升级,并提出一套通用解决方案即MOOC NEJ。MOOC NEJ已于今年年初上线验证,构建时长缩短至6分钟,提升70%构建性能,经过8个月的线上稳定运行,暂未发现引迁移引起的问题。</blockquote><h3>一、历史背景</h3><p>中国大学MOOC(以下简称中M)和许多网易前端er一样在过去几年搭建工程使用的框架为RegularJS与NEJ,同时在打包上采用NEJ的工具集toolkit2即NEJ Build(<a href="https://link.segmentfault.com/?enc=iTahJvE9vfFbB4Odytg%2FeQ%3D%3D.BXlyOgi97J2tJ2el6bTZi%2FhDt3wI2FBD8Lzwyz7qT7H5XLjBy72rkWmLEQckPw99" rel="nofollow">https://github.com/genify/toolkit2</a>)</p><p>在项目的开始总是愉快的,那时候开发维护NEJ的人还在(甚至还可以提一些feature),业务项目的人也还在,代码量总是清清爽爽,构建的时光总是“咻”一下就过去了。<br><img src="/img/remote/1460000040909113" alt="在这里插入图片描述" title="在这里插入图片描述"><br>然而!时间过去了三五年,当我接手到中M主站这个项目的时候,它的构建时间已经达到了<strong>将近21分钟</strong>。<br><img src="/img/remote/1460000040909114" alt="在这里插入图片描述" title="在这里插入图片描述"><br>平时等就等呗,最多在后端大佬部署的时候提前构建好等着,要命的是上完线已经10点了,这时候测试大佬来报:后端没问题了,前端这里有个问题修一下。</p><p>然后,大概就是修复5分钟,打包验证半小时。。。。。同时保佑不要再出(deng)一(ban)个(ge)问(xiao)题(shi)。</p><p><img src="/img/remote/1460000040909115" alt="在这里插入图片描述" title="在这里插入图片描述"></p><h3>二、究竟为啥这么慢?</h3><p>速度慢的原因有两方面:</p><p><strong> 1. 中M工程架构:</strong><br>由于历史原因,中M的主站将web和mobile端以及一并放在front-main项目下,同时,加载了两个看不出来有什么区别的超大lib,导致需要打包的文件非常多,东西多了自然就蚌埠住了。<br><img src="/img/remote/1460000040909116" alt="在这里插入图片描述" title="在这里插入图片描述"><br><strong>2. NEJ合并策略</strong><br>toolkit有一个合并策略的参数来决定一个文件引用计数超过多少次将合并入core.js,当这个参数设置的越大,core.js的size将越小,构建时间就越长。我们之前为了快速提高开发效率,在测试环境将该参数调至6,打包时间约11分钟,而预发和线上该参数在13,打包时间约21分钟。(毕竟我们不能因为想要打包快,就粗暴的使生产环境的资源变大😖)</p><h3>三、NEJ的合并策略为何影响打包速度【核心原理】</h3><p>由于NEJ已早早无人维护,但万幸它是开源的,咱们虽然找不到作者,但是可以通过对toolkit2的源码阅读来找到答案,我大致梳理了一下NEJ打包的流程。<br><img src="/img/remote/1460000040909117" alt="流程图" title="流程图"></p><ol><li>toolkit2全过程使用同步打包,每一步的处理结果都通过one by one的接力形式来传递。</li><li>toolkit2打包的本质是通过对合并策略(就是二.2提到的参数)等参数将每个文件的代码转成抽象语法树(AST),再对AST通过UgilifyJS进行压缩混淆。</li></ol><p>看起来这个流程没什么问题,因为AST和UgilifyJS在现在也都是很主流的操作(如webpack也是使用UgilifyJS),对前端来讲是再熟悉不过了。</p><p>那么为什么会导致打包慢呢?直到我看到了AST的生成过程:</p><blockquote><ol><li>先将所有文件通过concat生成<strong>一个大AST</strong>(源码的lib/adapter/script.js的_mergeCodeAndToAST<br><img src="/img/remote/1460000040909118" alt="在这里插入图片描述" title="在这里插入图片描述"></li><li><strong>对且仅对</strong>这一个AST进行Uglify压缩混淆(源码的lib/adapter/script.js的parse)<img src="/img/remote/1460000040909119" alt="在这里插入图片描述" title="在这里插入图片描述"></li></ol></blockquote><p>结合中M主站的历史架构,当我打印出这一个AST时,<br><strong>单主站web端的Core.js就大概由400个小AST形成。。。。</strong><br>然后它就开始压缩。。。。<br>然后它就卡住了。。。。住了。。。。了。。。。<img src="/img/remote/1460000040909120" alt="在这里插入图片描述" title="在这里插入图片描述"></p><h3>四、解决方案考量</h3><p>针对当前我们对自身工程和NEJ的了解,我们大概有以下几个思路去对打包时间进行优化:</p><blockquote><h4>【针对中M主站工程】</h4><ol><li>老生常谈的拆分工程&重构迁移:拆分需要对业务足够了解,才能对其做领域拆分。重构代价太大。同时功能回归点太多。目前一部分功能由于迭代需要已用React+Webpack做了替换。</li><li>webpack替换nej:需要兼容regular和nej模块,且对老项目内部也要替换webpack所需插件,成本较大。</li></ol><h4>【针对NEJ工具集】</h4><ol><li>Uglify2.0升级3.0:3.0有一个快速打包模式,但经过测试,3.0有部分不向下兼容的api,nej有用到。(此处有踩坑经验)</li><li><strong>多进程打包:不改造打包产物,只变更打包流程,成本可控,上线后风险较小,如遇到紧急问题可快速回滚至nej build,风险可控</strong></li></ol></blockquote><p><strong>最终,我们选择了给NEJ架上webpack同款多进程功能,打包需要什么,我们就造什么</strong>。</p><h3>五、实现架构</h3><p>我们并不想多造一个轮子,来做一些颠覆性的改变,使性能得到提升的同时,写的人也难受(考虑太多写一些冗余代码),使用的人也难受(迁移成本高)。<br>因此我们选择将toolkit2 fork下来,将其所有的api保留、功能保留,不影响任何老功能的使用姿势,只修改关键路径,缩小改造和迁移成本。</p><p>最后生成的库是:@edu/toolkit3<br><a href="https://link.segmentfault.com/?enc=Zkncbb4DWk5njDfGCEJJcw%3D%3D.%2Fu2O38pvhdDzZM1ZXNCrlmVmc8e7KMhZPEUG3jcRwJ7YbwXA%2Fn9GRS4CuAjNT9Ch" rel="nofollow">http://npm.hz.netease.com/package/@edu/toolkit3</a></p><p>我们的大致思路是这样:<br><img src="/img/remote/1460000040909121" alt="在这里插入图片描述" title="在这里插入图片描述"></p><ol><li>不对ast做concat操作,<strong>每个文件单独uglify</strong>。</li><li>参考uglify-webpack-plugin插件的多进程思路,<strong>将每个文件作为task,并发打包</strong>。<br>(我又去看了一下uglify-webpack-plugin的源码,做了一张图,毕竟知己知彼才能顺利改造。)<br><img src="/img/remote/1460000040909122" alt="在这里插入图片描述" title="在这里插入图片描述"></li><li><strong>将同步流程改成异步</strong>,完成所有task后回调结果,调用后续操作</li></ol><h3>六、代码实现</h3><h5>fork目录后,新增一个cluster文件夹用于存放多进程流程。</h5><p><img src="/img/remote/1460000040909123" alt="在这里插入图片描述" title="在这里插入图片描述"></p><ol><li>minifiy.js:执行ugilify压缩操作,生成压缩混淆后的ast和string代码;</li><li>TaskRunner.js:利用worker-farm来进行任务的分发、执行、计数、回调等;</li><li>worker.js: worker-farm必须要新建一个workerfile才能使用,这个workerfile用于承接;</li></ol><h5>再配合修改原文件lib/adapter/script.js</h5><ol><li>_mergeCodeAndToAST:取消concat以及this.ast的生成;</li><li>parse:增加callback参数,根据filename、file、minify参数创建task,run each task,并执行callback;</li></ol><h5>配合修改原文件lib/deploy.js</h5><ol><li>_afterResPrepared:将最后的embed(将变量嵌入css、js、html)操作改成异步,作为callback传入,等待完成所有的压缩后,执行最后输出前的embed操作。<br>(详细代码可以前往仓库查看)</li></ol><h3>七、使用方式&迁移(安利)</h3><p>说了这么久,终于要开始迁移,还记得标题吗,</p><h5>只需两步!无忧迁移!</h5><h5>不影响任何老功能的使用!</h5><p><strong>第一步:安装mooc-nej(目前稳定版本在0.3.0,0.4.0尚属beta版本,正在内测中)</strong></p><p>npm install @edu/toolkit3 -g</p><p><strong>第二步:使用它,甚至不用修改命令,只需将nej build替换为mooc-nej即可</strong></p><pre><code> // 所有的nej build改成mooc-nej build即可
mooc-nej build deploy/mobile/release.js
// 或者构建机安装直接引用build文件
<property name="mooc-nej-build.js" value="/home/appops/study/install/node_modules/@edu/toolkit3/bin/build.js"/>
<exec dir="." executable="${mooc-nej-build.js}"failonerror="true">
<arg line ="${ob_baseline.dir}/deploy/web/release.js"/>
</exec></code></pre><p><strong>backup:如果你发现有问题,还可以快速回滚到nej build。</strong></p><h3>八、生产验证</h3><p><img src="/img/remote/1460000040909124" alt="在这里插入图片描述" title="在这里插入图片描述"><br>中M在年初就已经使用上了mooc-nej,<strong>目前稳定运行半年有余,暂未发现任何由迁移引起的bug</strong>。<strong>请大家放心食用</strong>。</p><h3>九、总结</h3><p>大概从开始改造到结束改造用了去年12月的日常空余时间,其间的心路历程大致是:</p><blockquote><p>😢(打包好久555)</p><p>🤔(什么东西卡这么久)</p><p>😯(哇发现了原因)</p><p>😎(改造,冲!)</p><p>😏😤😏😤😏😤...(改完了挂了...又改完了又挂了...*n)</p><p>🤨(换个思路)</p><p>💓(忐忑验证)</p><p>😁(成功上线)</p></blockquote><p>但是回头看,这些踩坑都是值得的,我们终于不用再漫长的等待构建了!</p><p>-END-</p>
递推算法与递推套路(手撕算法篇)
https://segmentfault.com/a/1190000040879057
2021-10-28T16:30:10+08:00
2021-10-28T16:30:10+08:00
有道AI情报局
https://segmentfault.com/u/youdaotec
1
<p><img src="/img/remote/1460000040879059" alt="在这里插入图片描述" title="在这里插入图片描述"></p><p><strong>联系我们</strong>:<strong>有道技术团队助手</strong>:ydtech01 / <strong>邮箱</strong>:[<a href="mailto:ydtech@rd.netease.com">ydtech@rd.netease.com</a>]</p><p>之前学习基础知识的时候也说了,递推和动态规划有这暧昧不清的关系,可以说,动态规划就是多了一个决策过程的递推。因此,我们今天的刷题也会涉及到一些比较简单的动态规划的题目,同样能够对我们深刻的理解递推算法起到帮助,也为我们之后深入学习动态规划算法和动态规划的优化打下基础。</p><h2><strong>一、前置知识</strong></h2><p>每一个递推算法,都有一个递推公式,通过递推公式我们可以更加明确的了解递推算法。</p><h3>1.1 <strong>斐波那契数列的递推公式</strong></h3><p><strong>使用场景</strong>:当我们下(上)一行的状态可以仅仅通过上(下)一行的信息推导出来,并要求我们求解最后(第)一行时,我们无需将每一行的数据都存储在数组当中,可以通过滚动数组的技巧,节省空间复杂度。</p><p><strong>具体实现</strong>:假设我们已经知道第一行的数据,并且通过第一行数据经过一定的运算可以得到第二行的数据,那么,我们只需要一个数组临时存储两行数据,就可以将之后的每一行数据的结果计算出来,不断的滚动替换这两行数据,最终求解出最后一行数据的方式。<br><strong>关键点</strong>:推导计算当前行和下(上)一行的索引。由于数组是滚动的,因此,我们目标行的索引也是随时变化的,以下为求取当前行和上(下)一行的通用公式:</p><blockquote><strong>当前行</strong>: const curIdx = i % 2<br>由于我们的滚动数组只有2行,因此,当前索引只要与2求模即可;<br><strong>上(下)一行</strong>:const preIdx = +!curIdx<br>由于滚动数组只有两行,索引要么是0,要么是1,我们对当前行取反便可得到上(下)一行的索引(注意,在js中,对1取反是false,对0取反是true,因此我们通过一个隐式类型转换将布尔类型转换为数字类型)。</blockquote><p>这道题我们要怎么理解呢?我们如果想要爬到第 n 阶楼梯,那么上一步有可能是在 n-1 ,也有可能是在 n-2 </p><p><strong>实际使用:</strong>后面刷题时会经常用到,详见下文</p><h2>二、刷题正餐</h2><h3>2.1 LeetCode 120. 三角形最小路径和</h3><h4>2.1.1 解题思路</h4><p>按照我们之前将的递推套路:<br><strong>1. 定义递推状态</strong>:在这道题中,我们每走一步的路径和主要取决于当前所处的行数i和当前的列数j,因此,我们这道题的递推状态应该是:dp[i, j]<br><strong>2. 递推公式</strong>:确定了递推状态之后,我们就要确定递推公式了。那么,第i行第j列的数据要如何才能推导出来呢?首先,依据题意,我们要求最小路径和,如果我们从最底下往上走,那么我们可以知道,下一行i的数据应该是上一行i+1合法路径的最小值加上当前走到节点的值。<br>因此,我们得到了如下公式:</p><pre><code>// 第i行第j列数据的推导公式
dp[i, j] = min(dp[i+1, j], dp[i+1, j+1]) + val[i,j]</code></pre><p><strong>3.分析边界条件</strong>:我们需要将我们题目已知的条件初始化到我们的递推数组当中作为边界条件。这道题中,边界条件就是最后一行的数据,我们将最后一行的数据先加入到滚动数组当中,这样之后就可以根据最后一行数据不断的往上推导总路径和,从而找到最小路径。<br><strong>4. 程序实现</strong>:我们直接使用循环加滚动数组技巧实现。</p><h4>2.1.2 代码演示</h4><pre><code>function minimumTotal(triangle: number[][]): number {
const n = triangle.length;
// 递推公式(状态转义方程)以下为自底向上走的公式
// dp[i, j] = min(dp[i+1, j], dp[i+1, j+1]) + val[i,j]
// 由于i只跟i+1有关,因此,我们可以用滚动数组的技巧定义dp数组
let dp: number[][] = [];
for(let i=0;i<2;i++){
dp.push([]);
}
// 首先初始化最后一行的数值,由于使用了滚动数组的技巧,因此,我们最后一行的索引应该是(n-1)%2
for(let i=0;i<n;i++) {
dp[(n-1)%2][i] = triangle[n-1][i];
}
// 然后从倒数第二行开始求值
for(let i = n-2;i>=0;--i) {
// 由于使用了滚动数组,因此,当前行的下标为i%2,而下一行的下标则是当前行下标取反
let idx = i % 2;
let nextIdx = +!idx;
// 根据上面的公式计算出每一个位置上的值
for (let j=0; j <= i; j++) {
dp[idx][j] = Math.min(dp[nextIdx][j], dp[nextIdx][j + 1]) + triangle[i][j];
}
}
// 最终,三角形顶点的那个值就是我们要求的值
return dp[0][0];
};</code></pre><h3>2.2 LeetCode 119. 杨辉三角 II</h3><h4>2.2.1 解题思路</h4><p>这道题与上一道题类似,依然可以根据上一行推导出下一行的值,因此还是要祭出滚动数组的技巧,递推状态与递推公式的分析也比较类似,大家可以自己尝试推导。<br>而这一道题的边界条件,其实就是每一行的第一位都应该是1。</p><h4>2.1.2 代码演示</h4><pre><code>function getRow(rowIndex: number): number[] {
const res: number[][] = [];
// 初始化两行全部初始填充0的滚动数组
for(let i=0;i<2;i++)res.push(new Array(rowIndex+1).fill(0));
for(let i=0;i<=rowIndex;i++) {
// 计算滚动数组当前索引和上一行索引
let idx = i % 2;
let preIdx = +!idx;
res[idx][0] = 1;
// 计算每一行出第一位外其他位置的值
for(let j=1;j<=i;j++) {
res[idx][j] = res[preIdx][j-1] + res[preIdx][j];
}
}
// 滚动数组最后一行
return res[(rowIndex % 2)]
};</code></pre><h3>2.3 LeetCode 198. 打家劫舍</h3><h4>2.3.1 解题思路</h4><p><strong>1. 递推状态分析</strong>:<br>既然要求能够偷到的最多的金额,那么,假设最后一家是n,那么,最大金额与我们是否偷最后一家有直接的关系,我们需要分类讨论:</p><pre><code>a. 不偷最后一家:dp[n][0]其中,0代表不偷
b. 偷最后一家:dp[n][1]其中,1代表偷</code></pre><p><strong>2. 确定递推公式</strong>:<br>由于递推状态分为两种情况讨论,因此,我们的递推公式,也应该分成两个部分:</p><pre><code>a. 不偷最后一家:由于不能连续偷相邻的两家,如果最后一家不偷,那么,我们倒数第二家就可以偷,因此,此时我们的最大收益就取决于偷倒数第二家的金额与不偷倒数第二家金额的最大值。即:dp[n][0] = max(dp[n-1][0], dp[n-1][1])
b. 偷最后一家:由于要偷最后一家,那么就不能偷倒数第二家,因此,这种情况的最大收益是不偷倒数第二家获得的收益加上偷最后一家带来的收益,即dp[n][1] = dp[n-1][0] + nums[n]</code></pre><p><strong>3. 确定边界条件:</strong><br>依据题意,我们如果不偷第一家的时候,因为一家都没偷,此时收益应该为0。如果偷第一家,那么收益就是第一家的钱。至此,我们就确立了最开始的边界条件。<br><strong>4. 程序实现:</strong><br>这道题由于当前收益只取决于上一家的收益,因此依然使用滚动数组技巧加循环实现。</p><h4>2.3.2 代码演示</h4><pre><code>function rob(nums: number[]): number {
const n = nums.length;
// 由于不能连续偷两家,因此,最大收益应该分为两种情况讨论:
// 1. dp[n][0] = max(dp[n-1][0], dp[n-1][1]) 即:不偷最后一家的最后收益,取决于我偷倒数第二家的收益与不偷倒数第二家的收益的最大值
// 2. dp[n][1] = dp[n-1][0] + nums[n] 即:如果投了最后一家,那么倒数第二家就不能偷,所以最大收益就等于不偷倒数第二家的收益加上偷最后一家获得的收益
const dp: number[][] = [];
for(let i=0;i<2;i++) dp.push([]);
// 初始化第0家的值
dp[0][0] = 0;// 不偷第一家时收益为0
dp[0][1] = nums[0];// 偷第一家时收益为第一家的钱
for(let i=1;i<n;i++) {
// 使用滚动数组技巧
let idx = i % 2;
let preIdx = +!idx;
dp[idx][0] = Math.max(dp[preIdx][0] , dp[preIdx][1]);
dp[idx][1] = dp[preIdx][0] + nums[i];
}
// 最终收益最大值时不偷最后一家和偷最后一家的最大值
return Math.max(dp[(n-1) % 2][0], dp[(n-1) % 2][1]);
};</code></pre><h3>2.4 LeetCode 152. 乘积最大子数组</h3><h4>2.4.1 解题思路</h4><p><strong>1. 递推状态分析: </strong>我们要求最大子数组的乘积,那么我们可以用 dp[n] 代表最后一位是 n 的最大子数组乘积最大值。<br><strong>2. 递推公式: </strong>最后一个数是 n 有两种可能性,第一种是让 n-1 的最大乘积再乘上当前n的值,第二种则是 n 不与之前的值相乘,自开一国,因此,我们应该在这两种情况中选一个最大值,所以,递推公式应为:dp[n] = max(dp[n-1] * val[n], val[n])<br><strong>3. 边界条件:</strong>由于数组中可能含有负数,有可能使当前值乘上原先的最大值变成最小值,也可能时当前值乘上原先的最小值变成最大值,因此,我们不仅需要记录当前数字之前的最大值,也要记录最小值,方便处理负数的情况。而初始时,由于我们要求的是乘积关系,那么我们的最大值和最小值都初始化为1即可。<br><strong>4. 程序实现:</strong>由于第 n 项的最大乘积只跟 n-1 有关,我们可以使用变量方式存储关系,不需要额外开辟递推数组空间。</p><h4>2.4.2 代码演示</h4><pre><code>function maxProduct(nums: number[]): number {
// 递推状态:dp[n]代表以n作为结尾的最长连续子数组的乘积
// 递推公式:dp[n] = max(dp[n-1] * val[n], val[n]),即这个有两种可能,一种是以n作为结尾,然后乘上之前的最大值,另一种是不乘之前的值,自己独立成为一个结果
// 我们应该从这两种方案中选择一个较大的值作为结果
// 由于当前值只跟上一个值有关,我们可以使用变量方式来替代递推数组,又因为数组中可能存在负数,有可能导致当前值乘上之前的最大值变成了最小值,因此,我们
// 还需要额外记录一下数组中的最小值
let res = Number.MIN_SAFE_INTEGER;
// 由于是乘积关系,因此,最小值和最大值初始化为1
let min = 1;
let max = 1;
for(let num of nums) {
// 由于num是小于0的,因此,如果我们依然乘以原先的最大值,那就可能反而变成最小值,因此当num小于0时,我们交换最大值和最小值,这样,num乘以原先的最小值,就可以得到较大值了
if(num < 0) [min, max] = [max, min];
// 计算最大值和最小值
min = Math.min(min * num, num);
max = Math.max(max * num, num);
// 最终结果在上一个结果和max间取最大值
res = Math.max(res, max);
}
return res;
};</code></pre><h3>2.5 LeetCode 322. 零钱兑换</h3><h4>2.5.1 解题思路</h4><p><strong>1. 递推状态:</strong>我们使用多少个硬币,取决于要凑的金额的面额有多大,因此,我们的递推状态为:dp[n]<br><strong>2. 递推公式:</strong>假如我们要凑的面额是n,那么我们能凑够的最小的硬币数量应该是n-coin面额硬币数量再加上当前这枚硬币coin的数量1,并且每次都取最少的数量即可。因此,我们最终的递推公式应该是:dp[n] = min(dp[i-coin] + 1),即面额为n的钱需要我们在所有的拼凑方案中,取一个最小值,而每一个拼凑方案应该是当前面额i减去当前使用的硬币面额coin再加上当前硬币的数量1。<br><strong>3. 边界条件:</strong>当面额为0时,我们需要0枚硬币。<br><strong>4. 程序实现</strong>:我们再拼凑面额的时候,有一些特殊情况需要考虑,如:如果当前要拼凑的面额比硬币的面额要小,那么我们无法使用当前硬币拼凑成目标面额如果i-coin面额都无法拼凑成功的话,那么我们肯定也无法使用coin面额的硬币拼凑出目标面额,因为i-coin是前置条件。</p><h4>2.5.2 代码演示</h4><pre><code>function coinChange(coins: number[], amount: number): number {
// 我们需要计算出每一个面额所需要的硬币数量
let dp: number[] = new Array(amount+1);
// 初始时全部填充为-1代表不可达
dp.fill(-1);
// 拼凑0元需要0枚硬币
dp[0] = 0;
// 循环每一个面额
for(let i=1;i<=amount;i++) {
for(let coin of coins) {
// 如果当前要拼凑的面额比当前硬币还小,那就不能使用当前硬币
if(i < coin) continue;
// 如果没办法拼凑到dp[i-coin]的硬币,那么自然也不可能使用coin面额的硬币拼凑到dp[i]
if(dp[i - coin] === -1) continue;
// 如果当前的匹配方案没有使用过,或者使用当前匹配方案的结果比上一个匹配方案的结果大,说明我们找到了更小的拼凑方案,那么我们就把当前拼凑方案替换之前的拼凑方案
if(dp[i] === -1 || dp[i] > dp[i - coin] + 1) dp[i] = dp[i - coin]+1;
}
}
return dp[amount];
};
</code></pre><h3>2.6 LeetCode 300. 最长递增子序列</h3><h4>2.6.1 解题思路</h4><blockquote>概念扫盲:<br>a. 递增子序列:<br>你可以在一个完整的序列中,“跳着”选择元素,并且下一个元素必须不能小于上一个元素。所谓“跳着”,就是指元素索引不需要连续,如下面示例:</blockquote><pre><code>`# 原始序列
1, 4, 2, 2, 3, 5, 0, 6
# 递增子序列
1, 2, 2, 3, 5, 6`</code></pre><blockquote>b. 严格最长递增子序列:<br>严格递增子序列是在递增子序列的基础上多了一个限定条件,就是下一个元素不能等于上一个元素,只能大于,如下示例:</blockquote><pre><code># 原始序列
1, 4, 2, 2, 3, 5, 0, 6
# 严格递增子序列
1, 2, 3, 5, 6</code></pre><p><strong>1. 递推状态</strong>:由于我们最长递增子序列的长度与我当前以哪个元素作为最后一个元素有关,因此,我们的递推状态为:dp[n],代表以n位置作为结尾的最长递增子序列的长度<br><strong>2. 递推公式</strong>:我们要算出以第n个元素作为结尾的最长递增子序列的长度,就要找出他上一个合法的最长递增子序列的最后一个元素j,而我们第n个元素作为结尾的最长递增子序列长度就是上一个最长递增子序列长度加一,我们只需要将所有满足这个条件的最长递增子序列长度的最大值求出来就是最终的结果,即:dp[n] = max(dp[j] + 1) | j<n & val(j) < val(n)<br><strong>3. 边界条件</strong>:n为1时,最长递增子序列长度为1<br><strong>4. 程序实现</strong>:由于我们的递归状态定义的是以n作为结尾的最长递增子序列的长度,因此,我们每一项的初始值默认都是1,至少要选择当前的数。</p><h4>2.6.2 代码演示</h4><pre><code>function lengthOfLIS(nums: number[]): number {
const dp: number[] = [];
let res: number = Number.MIN_SAFE_INTEGER;
for(let i=0;i<nums.length;i++) {
// 每一项都初始化为1,因为dp[i]代表以i位置作为结尾的最长递增子序列的长度,那我们最少都应该选择i这个数,长度就是1
dp[i] = 1;
// 在i位置之前找到满足nums[j] < num[i]的值
for(let j = 0; j < i;j++) {
if(nums[j] < nums[i]) dp[i] = Math.max(dp[i], dp[j] + 1);
}
// 到此,我们已经找到了一组最长递增子序列了,更新一下res
res = Math.max(res, dp[i]);
}
return res;</code></pre><h2>三、结语</h2><p>今天的刷题到此暂告一段落。<br>从上面刷的几道题其实我们不难发现,无论是递推算法还是动态规划,都有一定的套路可循,虽然这个套路学起来也并不简单,但是至少有了明确的学习方向,我们可以通过:递推状态定义、递推公式(状态转移方程)推导、边界条件确立、程序实现这四个通用套路将一个看似复杂的递推或动规程序逐一分析解决。<br>当然,上面的程序实现,为了更容易理解,没有使用太多的技巧进行优化,并不一定是最优的,未来如果有机会,将和大家分享动态规划的优化技巧。也欢迎大家与我们多多交流~</p><p>-END-</p>
递推算法与递推套路(算法基础篇)
https://segmentfault.com/a/1190000040807819
2021-10-13T18:27:04+08:00
2021-10-13T18:27:04+08:00
有道AI情报局
https://segmentfault.com/u/youdaotec
3
<p><img src="/img/bVcUofz" alt="" title=""></p><p><strong>联系我们</strong>:<strong>有道技术团队助手</strong>:ydtech01 / <strong>邮箱</strong>:[<a href="mailto:ydtech@rd.netease.com">ydtech@rd.netease.com</a>]</p><p>相信了解算法同学经常会说动态规划太难了,看到题目完全不知从何下手,或者是说“一看题解就会,一看题目就废”这样的一个状态。本质上是由于学习动态规划的时候,学习方法不对,最终导致南辕北辙,没有掌握其中精髓。而动态规划与递推算法又有着暧昧不清的关系,我们选择先从递推算法入手,一步一步揭开动态规划的神秘面纱。</p><h2><strong>一、</strong><strong>递推公式</strong></h2><p>每一个递推算法,都有一个递推公式,通过递推公式我们可以更加明确的了解递推算法。</p><h3>1.1 <strong>斐波那契数列的递推公式</strong></h3><p><strong>f(n) = f(n-1) + f(n-2)</strong> ,这是我们斐波那契数列的递推公式,有很多同学可能会问,这个公式实际有什么用呢?接下来,我们来直接看一个算法题:<strong>爬楼梯</strong></p><h4><a href="https://link.segmentfault.com/?enc=WIwhmYMRm7p89aQbfpoEMg%3D%3D.ZHrtWekfqqWn9oLyhudnNw%2F2kLN4WS3D39naMmNHFMlqvpDdHDikxThDq%2FXGQSbju%2Fm0Fd0HgwUDZuHdYEGoaQ%3D%3D" rel="nofollow">LeetCode 70. 爬楼梯</a></h4><p>这道题我们要怎么理解呢?我们如果想要爬到第 n 阶楼梯,那么上一步有可能是在 n-1 ,也有可能是在 n-2 </p><p><img src="/img/bVcVkqx" alt="" title=""></p><p>因此,这道题的解法就一目了然了:</p><pre><code class="js">function climbStairs(n: number): number {
const res: number[] = [];
// 爬到第0层的方法就是一动不动,我们可以认为他只有一种
res[0] = 1;
// 爬上第一层阶梯的可能性只有1个,就是走一步
res[1] = 1;
// 因此,后面的爬楼方式,我们就可以通过地推方式计算出来
for(let i=2;i<=n;i++) {
res[i] = res[i-1] + res[i-2];
}
return res[n];
};</code></pre><h2><strong>二、</strong><strong>数学归纳法</strong></h2><p>上面带着大家一起学习了一下斐波那契数列递推公式的实际应用。那么,为什么上面这个公式就能够描述这一类问题的特性呢?这就要再聊一下<strong>数学归纳法</strong>了。</p><p><strong>数学归纳法</strong>在整个计算机科学当中是非常重要的,主要分为以下几步:</p><ol><li><strong>验证k0成立(边界条件);</strong></li><li><strong>证明如果k(i)成立,那么k(i+1)也成立;</strong></li><li><strong>联合步骤1和步骤2,证明由k0~k(n)成立。</strong></li></ol><p>不知道大家是否还记得,之前我们学习二叉树时,有扩展学习过<strong>递归程序的设计</strong>,而<strong>递归程序的设计</strong>要点就是<strong>数学归纳法</strong>在广义方面的应用,又称为<strong>结构归纳法</strong>。</p><p>那么,我们再来看一下上面的爬楼梯问题,怎么使用<strong>数学归纳法</strong>分析。</p><ol><li><strong>验证k0成立</strong>:在爬楼梯问题中,我们的边界条件就是当n为0和n为1。</li><li><strong>证明如果k(i)成立,那么k(i+1)也成立</strong>:我们假设 res[i-1] 和 res[i-2] 是正确的,那么,res[i]也是成立的。</li><li><strong>联合步骤1和步骤2,证明由k0~k(n)成立</strong>:由于步骤1和步骤2联立,必然能够的出res[n]是成立的。</li></ol><h2><strong>三、</strong><strong>如何求解递推问题(递推问题的求解套路)</strong></h2><blockquote>论求解套路的重要性:求解套路就是递推算法的学习方式,如果学习方式错了,很可能南辕北辙,花了比别人更多的时间,反而掌握的更少。就像健身的时候,如果你掌握了一些动作要领,可能1~2个月肌肉就出来了,但是你要是没有掌握动作要领,练错了,不仅长得肌肉变成肥膘,还可能伤到自己。</blockquote><ol><li><p><strong>确定递推状态(重点)</strong></p><ul><li><p>一种函数符号 <strong>f(x)</strong> 以及赋予函数符号一个含义</p><ul><li>例如上面的斐波那契数的求解问题,我们要赋予 f(x) 一个含义: 爬上第x阶楼梯的方法总数</li></ul></li><li><p>函数所对应的值就是我们要求解的答案</p><ul><li>如:f(x) = y 中, <strong>x</strong>是自变量, <strong>y</strong> 是因变量。而在上面爬楼梯的问题当中,自变量<strong>x</strong>就是要爬的楼梯数,而因变量 y 就是爬到 x 阶楼梯的方法总数。因此,我们再求解问题的时候,最终要的是确定哪个是自变量,哪个是因变量。通常,<strong>因变量的值</strong>就是我们要求解的值。</li><li>那么,我们要如何分析题目中的自变量是什么呢?我们要确定,会影响因变量的因素有哪些。如爬楼梯问题中,影响方法总数的就只有我们当前要爬的楼梯数,因此,<strong>自变量就是</strong>楼梯数 x。</li></ul></li><li><strong>思维练习</strong></li></ul></li></ol><p><img src="/img/bVcVksk" alt="" title=""></p><ul><li>首先来分析一下递推状态是什么。</li></ul><p>首先第一个会影响我们方法总数的自变量就是 n ,即房间被划分成了几个区块,其次,由于房间是环形的,为了不让首尾颜色项目,我们还需要将首尾颜色也记录到我们的递推状态当中,那么,我们就得到了如下的公式:</p><p>f(n, i, j) = y,其中,n代表一个房间被分成几个区块,i 和 j 分别代表首尾颜色, y 代表方法总数。这个公式的意思是:<strong>总共有n个区块的房间,第一个区块涂第i种颜色,最后一个区块涂第j种颜色并且相邻颜色不同的方法总数为y</strong></p><ul><li><p>递推公式</p><p>上面分析得出了 <strong>f(n, i, j) = y</strong> 这样一个简易版的公式,现在,我们就需要确定,通过怎样的运算能够算出f(n, i, j)</p></li></ul><p><img src="/img/bVcVksH" alt="" title=""></p><p>注意,上面三个递推公式都是正确的,只是在不断的优化我们的递推公式,提升程序效率,但三种方式都可以求解出正确答案</p><ol start="2"><li><p><strong>确定递推公式</strong></p><ul><li>确定 f(n) 依赖于哪些 f(i) 的值</li></ul></li><li><strong>分析边界条件(k0)</strong></li><li><p><strong>程序实现</strong></p><ul><li><strong>递归</strong></li><li><strong>循环</strong></li></ul></li></ol><h2><strong>四、</strong><strong>递推与动态规划</strong></h2><p><strong>动态规划问题其实就是求解最优化的递推问题,动态规划问题相较于普通的递推问题,多出了一个决策的过程</strong></p><p>空讲概念有点抽象,我们来结合具体问题来分析。依旧还是爬楼梯问题,不过比之前的爬楼梯多了一个体力花费。</p><h3><a href="https://link.segmentfault.com/?enc=QnjINq60fXcCBb1Hn05WfQ%3D%3D.SCYtn1U8fqiqXjRAa01oTFLp9j%2FEiCd7Q9sYQ5WFQwwH7aSOqFJeBF8ZMDlY1l3n7iQ2t1qqDbNUpfNTAZyaLA%3D%3D" rel="nofollow">LeetCode 746. 使用最小花费爬楼梯</a></h3><p>这道题与上面简单的爬楼梯问题类似,差别就在于每上一层楼梯,我们需要花费一定的体力,要求我们求出花费最小的体力。</p><p><img src="/img/bVcVksI" alt="" title=""></p><p>通过上面的分析,我们可以得出以下<strong>公式</strong>:dp[n] = min(dp[n-2] + cost[n-2], dp[n-1] + cost[n-1])</p><p>翻译一下上面的公式:<strong>爬上第n层楼梯的总体力花费应该等于最后一步从第n-2层爬上来的体力花费与最后一步是从n-1层爬上来的体力花费的最小值</strong></p><pre><code class="js">function minCostClimbingStairs(cost: number[]): number {
const n = cost.length;
const dp: number[] = [];
// 由于题目给定可以从第0层或第1层开始爬,因此,我们初始化第0层和第1层的体力花费为0
dp[0] = 0;
dp[1] = 0;
// 我们从第二层的体力花费开始递推
for(let i=2;i<=n;i++) {
// 第i层的体力花费是我最后一步从i-1层爬上来的体力花费与从i-2层趴上来的体力花费的最小值
dp[i] = Math.min(dp[i - 1] + cost[i - 1], dp[i - 2] + cost[i - 2]);
}
return dp[n];
};</code></pre><h2><strong>五、结语</strong></h2><p>大家都觉得动态规划学起来很难,主要是因为我们要真正学好动态规划,需要从:<strong>递推状态定义、状态转移方程推导、程序实现</strong>等三个大方向上入手并学习,并且这三个方向都是不好学的。今天通过递推算法与递推公式的相关学习,以及初步的了解了递推算法与动态规划的关系。这些都是我们后续学习动态规划的基础,其中尤为重要的是数学归纳法的理解与应用。“光说不练假把式”,今天说的大部分都是理论,<strong>下一篇文章</strong>《递推算法与递推套路(手撕算法篇)》将会直接从一些递推或动态规划的题目入手,学习递推程序或动态规划程序的求解套路,让你看到递推和动规不再茫然。敬请期待!</p>
玩转TypeScript工具类型(下)
https://segmentfault.com/a/1190000040719575
2021-09-22T18:04:35+08:00
2021-09-22T18:04:35+08:00
有道AI情报局
https://segmentfault.com/u/youdaotec
4
<p><img src="/img/bVcUofz" alt="" title=""><br><strong>联系我们</strong>:<strong>有道技术团队助手</strong>:ydtech01 / <strong>邮箱</strong>:<a href="mailto:ydtech@rd.netease.com">ydtech@rd.netease.com</a></p><p>本文是《玩转TypeScript工具类型》系列的最后一篇,包含了如下几部分内容:</p><ul><li><strong>ThisParameterType<Type></strong></li><li><strong>OmitThisParameter<Type></strong></li><li><strong>ThisType<Type></strong></li></ul><p><strong>快捷跳转</strong></p><ul><li><a href="https://segmentfault.com/a/1190000040653014">玩转TypeScript工具类型(上)</a></li><li><a href="http://segmentfault.com/a/1190000040690347">玩转TypeScript工具类型(中)</a></li></ul><h2>一. ThisParameterType<Type></h2><p>提取一个函数类型<strong>显式</strong>定义的 this 参数,如果没有显式定义的 this 参数,则返回 unknown 。 这里有如下几个需要注意的点:</p><blockquote><ul><li>this参数只能叫 this,且必须在参数列表的第一个位置</li><li>this 必须是显式定义的</li><li>这个 this 参数在函数实际被调用的时候不存在,不需要显式作为参数传入,而是通过 call、apply或者是 bind 等方法指定</li></ul></blockquote><h3>1.1 源码解析</h3><pre><code>type ThisParameterType<T> = T extends (this: infer U, ...args: any[]) => any ? U : unknown;</code></pre><p>从源码可以看出对于类型参数 T 是要严格匹配 <strong>(this: infer U, ...args: any[]) => any</strong>格式的,所以对于 this 参数的名称和位置都是固定的。剩下的逻辑就是对 this 参数的类型定义一个类型参数 U ,在 extends 判断走 true 分支时返回 this 类型参数 U ,false 分支就返回 unknown。</p><h3>1.2 实战用法</h3><p>显式的定义 this 类型有助于我们在函数内部<strong>安全的</strong>使用 this 。</p><pre><code>function toHex(this: Number) {
return this.toString(16);
}
function numberToString(n: ThisParameterType<typeof toHex>) {
return toHex.apply(n);
}</code></pre><p><strong>注:定义了一个函数,要使用这个函数的类型,可以直接使用 typeof [funcName] ,可以省去额外再定义一个类型声明。</strong></p><h2>二. OmitThisParameter<Type></h2><p>有了 <strong>ThisParameterType</strong>获取 this 的类型,那么如何将一个定义了 this 参数类型的函数类型中的this 参数类型去掉呢? 这就是 <strong>OmitThisParameter</strong> 做的事情。</p><p>一句话概括,就是<strong>对于没有定义 this 参数类型的函数类型,直接返回这个函数类型,如果定义了 this 参数类型,就返回一个仅是去掉了 this 参数类型的新函数类型。</strong></p><h3>2.1 源码解析</h3><pre><code>type OmitThisParameter<T> = unknown extends ThisParameterType<T> ? T : T extends (...args: infer A) => infer R ? (...args: A) => R : T;</code></pre><p>似乎有点长,其实就是两个嵌套的 <strong>extends</strong>条件判断,分成两部分就很好理解了,<strong>首先</strong>是:</p><pre><code>unknown extends ThisParameterType<T> ? T : ...</code></pre><p>对于传入的函数类型 T ,首先使用 ThisParameterType 获取 this 参数的类型,可能有两种结果一种是<strong>成功拿到 this 参数类型并返回</strong>,另一种是 unknown 。 所以如果返回的是 unknown ,那么就是走 true 分支,直接返回 T 。如果不是返回的 unknown ,那么就走 false 分支,即:</p><pre><code>T extends (...args: infer A) => infer R ? (...args: A) => R : T</code></pre><p>又是一个条件判断,即只要 T 是一个合法的函数类型,就一定满足 <strong>(...args: infer A) => infer R</strong>,剩下的就是对参数定义一个类型参数 A ,对返回值定义一个类型参数 R ,返回<strong>(...args: A) => R </strong>,这个新的函数类型已经不包含 this 了。</p><h3>2.2 实战用法</h3><pre><code>function toHex(this: Number) {
return this.toString(16);
}
const fiveToHex: OmitThisParameter<typeof toHex> = toHex.bind(5);
console.log(fiveToHex());</code></pre><h2>三. ThisType<Type></h2><p>这个工具类型非常特殊,第一个特殊之处就是它的<strong>源码定义</strong>,是一个<strong>空接口</strong>:</p><pre><code>/**
* Marker for contextual 'this' type
*/
interface ThisType<T> { }</code></pre><p>那么 ThisType 的作用是什么呢?正如官方注释所写的:<strong>作为上下文 this类型的标记。</strong></p><blockquote>要使用 ThisType 必须保证 noImplicitThis 配置开启,后续我们只讨论开启的情况</blockquote><p>那么如何理解这句话呢?我们需要从实际效果来理解,先看如下这段代码:</p><pre><code>let demo1 = {
a: 'lipengpeng',
test(msg: string) {
this;
}
};</code></pre><p>它的 this 类型是什么呢?</p><pre><code>this: {
a: string;
test(msg: string): void;
}</code></pre><p>也可以手动指定 this 类型,比如:</p><pre><code>let demo2 = {
a: 'lipengpeng',
test(this:{a: string}, msg: string) {
this;
}
};</code></pre><p>这时的 this 类型就是</p><pre><code>this: {
a: string
}</code></pre><p>其实这只是<strong>理想情况下的 this 类型分析</strong>,因为 TypeScript 是通过静态代码分析推断出的类型,在实际运行阶段的 this 是可能发生变化的,那么我们<strong>如何指定运行阶段的 this 类型呢?</strong> 。</p><p>如果只看如上两种情况,可能觉得不用 ThisType 也足够了,因为 TypeScript 会推断 this 类型,但是这只是简单情况,就如我们之前提到的,运行阶段的 this 是可以改变的,所以仅是依赖代码分析是无法预测到未来的 this 类型的,这时候就需要借助我们的主角—— ThisType 了。 我们继续从实际的使用场景入手,实际开发中我们定义一个对象有时候会给一个数据结构,就类似于 <strong>Vue2.x Options API</strong> :</p><pre><code>let options = {
data: {
x: 0,
y: 0
},
methods: {
moveBy(dx: number, dy: number) {
this.x += dx;
this.y += dy;
}
}
}</code></pre><p>我们希望在 moveBy 的 this 对象上可以直接获取到 data 对象中的 x和y 。为了实现这个功能,我们需要对定义的数据结构做一些处理,让 methods 和 data 中的属性共享同一个 this 对象,因此我们需要一个工具方法 <strong>makeObject</strong> :</p><pre><code>function makeObject(config) {
let data = config?.data || {}
let methods = config?.methods || {}
return {
...data,
...methods
}
}
let options = makeObject({
data: {
x: 0,
y: 0
},
methods: {
moveBy(dx: number, dy: number) {
this.x += dx;
this.y += dy;
}
}
})</code></pre><p>方法也很简单,就是把 data 和 methods 展开,放在同一个对象 options 中,当我们通过options.moveBy() 的方式调用 moveBy 的时候,moveBy 的 this 就是这个对象。 功能实现了,那么如何实现类型安全呢?接下来就需要在 makeObject 方法上做一些改动了,重点就是定义<strong>参数类型</strong>和<strong>返回值类型</strong>:</p><pre><code>// 只考虑传入makeObject的config参数只包含data和methods两个参数
// 定义两个泛型参数D & M来代表它们的类型
type ObjectConfigDesc<D, M> = {
data: D
methods: M
}
function makeObject<D, M>(config: ObjectConfigDesc<D, M>): D & M {
let data = config?.data || {}
let methods = config?.methods || {}
return {
...data,
...methods
} as D & M
}</code></pre><p>此时 options 对象的类型已经是类型安全的了。但是我们最关心的 moveBy 中的 this 对象却仍然会报类型警告,但我们知道在实际的运行过程中, moveBy 中的 this 对象已经可以取到 x 和 y 了,最后一步就是<strong>明确告诉 TypeScript 这个 this 对象的真实类型了</strong>,非常简单,利用 ThisType :</p><pre><code>type ObjectConfigDesc<D, M> = {
data: D
methods: M & ThisType<D & M>
}</code></pre><p>这时候再看 options 的类型提示已经是正确的了:</p><pre><code>let options: {
x: number;
y: number;
} & {
moveBy(dx: number, dy: number): void;
}</code></pre><p>大家可以在 TypeScript Playground 中<a href="https://link.segmentfault.com/?enc=teE%2FIjttQwKntZwmI0JlOg%3D%3D.YQzZGXFsMpMOMGJY%2FIMGMezIgfJQKT%2FkkSdr%2Fqxm6Hofw762B8CtrzSwCZVV%2FV1SUNtyFtCBLoTPFXTzmXd%2FDam0ZamqNxRagTtBv%2FRU%2FyTwBCWgsX7KoK%2FhxdbCBexXshKTXXUCfCASN%2BirKAYlEZlYQMZiKeXHnaiP85jqzhiLXHanEu30qY1pEuT6WJgWq1dYpNbTA8ahqwIFobliMacreWSMOHyfLXBnCslOY0KqvT9vH%2FCpRPTi7hbl1D8QB45kbc1LcnIe9rL7bI%2FGkNXM00OIzvauOyt6RP709x8qyTb5H9YlW3GEGdn1vMfn6%2FVd2FTl1qZCsiCbzoyvJJF%2FJkFuXeovP5%2FF72rr4RIDYomFYJLiKvrxylZZFiKfBopdHXFRlbAFMNAkL0bqrunkbFtBEMy2%2BXSvzDqxCOGRDDCUqp7PwFo%2FTLqhil0MG%2BOuG5CzWhyvFXwdu5Bs9zLLy%2Fq7T2ScYiy6RFwbWHcz0L1%2Fmfw%2FAqMAyuyKqvkz5kgbVBQp4ygosag7%2Fh5TLj%2B8SzMxNRf%2Fcu7co1b9SWz6RHztQ9EM8V0hv9TEZ7fs%2BetrIZFIa5fiMg8RhWja3UVNWDj7sqJoEfaRZ4GHUSUddfiqHEAm9J%2BQ%2BatRcQNaMbXGR8zLa9byq4cnLpS41ToauItyFDHlcAf32%2Bd3tAIK8ANucN%2FReHlae6wRpRYQ0%2F6rZmX54r7CAS%2Fht2iK4tiTkWMBKxGhlRlLHIHADi20hrEt%2FTUIx6CdYOg09mbsikUomUIwSWqY21A4yKpPGVu4OqizEj66ihMXg14lnsxYikDMEzyUqSWQMmhFtJCxXgdf%2F5Sj7Su2FL8MHmXB0uEHyAFm0P8hQGEGF5SYBBI8QKv8xe29yjZcSkGQ%2BtxI" rel="nofollow" title="https://www.typescriptlang.org/play?#code/C4TwDgpgBA8gRgKwgY2AYQPYDsBmBLAcwBEIBnZAHiIBooBZAPigF4oBvAKCigBMBDYHwBcUIlygBbCMAAWGHqRF0oAMigAVGXlLrwEKqvoMOAXw4ccAVyyo82SXwDWEeElRVajABTJs+AiKuKOh+hCTkHkYAlCJEhsqc3AA20rwCfCxQvriEAPwAdPyCUAA+JexmyalSsvKkmdn+BTVyCqXlbJVQAE7Slt1Y7OLc+aNFfNTDUKP5LXXiJlB89XFqdKZcHCnAUBiImRJOLojBXolpgiJsUAAeIgAMtCAPUCaT3HMKV1MSGABuEAAQiAvDw7lAsJYJHAIN1aDxnhCoTDulEhtwMVBZNp8jcoABqVhggDcUAA9GSoABlYDdbAEJIgLF6HhYrSkKbcbGkfJMwm8ECkinU2n0xnMyCs7lTN4LSYmKLE8zbXZgYB2LD1ViHZxBVBncTjb6Y8GPKaI+5y8SfRTojG-AHA0HgyHQ2HwxGulFo86Y7m4glEm5KzFc9m8wMCkMYrpmBXmDBqjU8m4cRPq7A8kBppOZ2b-IEggCMtCLUSAA">亲手试一试</a>,感受会更深刻一些。</p><p><strong>注意:ThisType 仅支持在对象字面量的上下文中使用,在其他地方使用作用等同于空接口。</strong></p><h2>四. 结束语</h2><p>截止到这里,《玩转TypeScript工具类型》系列总计三篇就全部完成了,写这个系列其实就是想记录自己学习过程中的一些学习思路和感受,同时通过文章的方式写下来加深自己的理解,所以如果有任何错误的地方欢迎批评指正。</p>
玩转TypeScript工具类型(中)
https://segmentfault.com/a/1190000040690347
2021-09-15T17:30:09+08:00
2021-09-15T17:30:09+08:00
有道AI情报局
https://segmentfault.com/u/youdaotec
4
<p><img src="/img/bVcUofz" alt="" title=""></p><p><strong>联系我们</strong>:<strong>有道技术团队助手</strong>:ydtech01 / <strong>邮箱</strong>:<a href="mailto:ydtech@rd.netease.com">ydtech@rd.netease.com</a></p><p>本文是《玩转TypeScript工具类型》系列的第二篇,包含了如下几部分内容:</p><ul><li><strong>必读:extends条件运算符</strong></li><li><strong>Exclude<Type, ExcludeUnion></strong></li><li><strong>Extract<Type, Union></strong></li><li><strong>NonNullable<Type></strong></li><li><strong>必读:tuple type元组类型</strong></li><li><strong>Parameters<Type></strong></li><li><strong>ConstructorParameters<Type></strong></li><li><strong>ReturnType<Type></strong></li><li><strong>InstanceType<Type></strong></li></ul><h2><strong>一. 必读:extends条件运算符</strong></h2><p>因为后续的源码中涉及到了 extends 关键字,所以需要先提前掌握这部分内容才能更好更容易的理解源码。可以参考[[[译]TypeScript条件类型]](<a href="https://link.segmentfault.com/?enc=MYuKvgou83SjCikkdczR0A%3D%3D.FPIeT3uc7BtNZD2DyaWngKysihwyfNBEjcrOickm95fJYeN8aKiMxGeQpm%2B3O9Xr" rel="nofollow">http://juejin.cn/post/6985463...</a>),英语好的同学推荐直接看原文。</p><h2><strong>二. Exclude<Type, ExcludeUnion>:排除</strong></h2><p>将 ExcludeUnion 联合类型的所有成员从 Type 中排除,可以理解为<strong>取差集</strong>。剩余的部分返回作为一个新类型。</p><h3><strong>2.1 源码解读</strong></h3><pre><code>type Exclude<T, U> = T extends U ? never : T;</code></pre><p>如何理解 T extends U ? never : T 呢?<strong>判断T中的每一项是否可以赋值给类型U,如果可以,就返回never,如果不可以,就返回当前这项。</strong></p><p>接下来我们结合一个具体的例子来理解一下这句话。</p><h3><strong>2.2</strong> <strong>实战用法</strong></h3><pre><code>// 排除一个具体的值
type T0 = Exclude<"a" | "b" | "c", "a"> // "b" | "c"
// 排除一种类型
type T1 = Exclude<string | number | (() => void), Function></code></pre><p>以 T0 为例,extends 关键字是如何发挥作用的呢?</p><pre><code>
type T0 = Exclude<"a" | "b" | "c", "a"> // "b" | "c"
// 等价于
type T0 = "a" extends "a" ? never : "a"
| "b" extends "a" ? never : "b"
| "c" extends "a" ? never : "c"
// 等价于
type T0 = never | "b" | "c"
// 等价于
type T0 = "b" | "c"</code></pre><p>如果无法理解这里的等价逻辑,建议阅读开头推荐的[[[译]TypeScript条件类型]<br>](<a href="https://link.segmentfault.com/?enc=HEKuOKBOYMwPsVNVwxb5Ig%3D%3D.uELoX5qPQ%2F3Ht0rnOQi1xWfKP2ZxMwrJxQj7io9MUMPyycaRxUPQDfDcRZfsgHmA" rel="nofollow">https://juejin.cn/post/698546...</a>)</p><h2><strong>三. Extract<Type, Union>:提取</strong></h2><p>选取 Type 类型和 Union 类型两者的公共部分并返回为一个新类型,可以理解为<strong>取交集</strong>。</p><h3><strong>3.1 源码解析</strong></h3><pre><code>/**
* Extract from T those types that are assignable to U
*/
type Extract<T, U> = T extends U ? T : never;</code></pre><p>提取出类型 T 中那些能赋值给类型 U 的类型。所以<strong>这里源码上和 Exclude 的区别就在于 never 放在了条件运算结果为 false 的分支上。</strong></p><p>所以如果理解了 Exclude ,再理解 Extract 就不复杂了。</p><h3><strong>3.2 实战用法</strong></h3><pre><code>// 提取一个具体的值
type T0 = Extract<"a" | "b" | "c", "a"> // "b" | "c"
// 提取一种类型
type T1 = Extract<string | number | (() => void), Function></code></pre><p>这里就不再重复说明 extends 的处理逻辑了。</p><h2><strong>四. NonNullable<Type></strong></h2><p>过滤掉 Type 中的 null 和 undefined,剩余的类型作为一个新类型返回。其实就是 Exclude 的一种特殊情况。</p><h3><strong>4.1 源码解析</strong></h3><pre><code>type NonNullable<T> = T extends null | undefined ? never : T</code></pre><p>可以发现和 Exclude<T, U> 的源码非常像,只是把 U 换成了 null | undefined 。所以结合Exclude<T, U> 还是很好理解的。</p><h3><strong>4.2 实战用法</strong></h3><pre><code>type T0 = NonNullable<string | number | undefined>;
// type T0 = string | number
type T1 = NonNullable<string[] | null | undefined>;
// type T1 = string[]</code></pre><h2><strong>五. 必读:tuple type元组类型</strong></h2><p>元组类型就是一个具有<strong>固定数量元素</strong>和<strong>元素类型都确定</strong>的数组类型。</p><blockquote>Tuple types allow you to express an array with a fixed number of elements whose types are known, but need not be the same.</blockquote><p>例如这就是一个元组类型:</p><pre><code>let x: [string, number];</code></pre><p>当我们访问已声明类型的元素时,可以获得正确的类型检查:</p><pre><code>
// OK
console.log(x[0].substring(1));
// Property 'substring' does not exist on type 'number'.
console.log(x[1].substring(1));</code></pre><p>当我们访问超出数组长度的下标时,获得的类型都是 undefined ,并且会获得一个访问下标无可访问元素的错误提示:</p><pre><code>let x: [string, number];
x = ["hello", 10]; // OK
// Type '"world"' is not assignable to type 'undefined'.
// Tuple type '[string, number]' of length '2' has no element at index '3'.
x[3] = "world";
// Object is possibly 'undefined'.
// Tuple type '[string, number]' of length '2' has no element at index '5'.
console.log(x[5].toString());</code></pre><h2><strong>六. Parameters<Type></strong></h2><p>基于一个函数参数的类型构造一个元组类型(tuple type)。所以这个工具类型的作用就是<strong>获取函数参数的类型</strong>。</p><h3><strong>6.1 源码解析</strong></h3><pre><code>/**
* Obtain the parameters of a function type in a tuple
*/
type Parameters<T extends (...args: any) => any> = T extends (...args: infer P) => any ? P : never;</code></pre><p>源码中重点有两部分:</p><ul><li>T extends (...args: any) => any ,规定了 T 必须是一个函数( any 和 never除外),参数是 any 类型,所以参数可以是任意类型。</li><li>T extends (...args: infer P) => any ? P : never,如果 T 是函数类型,那么这个 extends 走的就是 true 分支,也就是返回一个 P 类型,而这个 P 类型就是参数 args 的类型</li></ul><p>如果对 infer 无法理解的同学可以再<a href="https://link.segmentfault.com/?enc=BWORWEdSBAh9dEk%2FK0V06w%3D%3D.RdoCLIG2NEuCQ815uH6xXN%2FInWeTZJyRfg5HSGBAp%2BP5%2Fn2Ae6bc3oYoDHYDe4%2FamTMllhg%2FFugoFEHK4eQfAA%3D%3D" rel="nofollow" title="https://juejin.cn/post/6985463429502877726#heading-5">回顾一下</a></p><h3><strong>6.2 实战用法</strong></h3><pre><code>declare function f1(arg: { a: number; b: string }): void;
type T0 = Parameters<() => string>;
// type T0 = []
type T1 = Parameters<(s: string) => void>;
// type T1 = [s: string]
type T2 = Parameters<<T>(arg: T) => T>;
// type T2 = [arg: unknown]
type T3 = Parameters<typeof f1>;
// type T3 = [arg: { a: number; b: string; }]
type T4 = Parameters<any>;
// type T4 = unknown[]
type T5 = Parameters<never>;
// type T5 = never
type T6 = Parameters<string>;
// Type 'string' does not satisfy the constraint '(...args: any) => any'.
type T7 = Parameters<Function>;
// Type 'Function' does not satisfy the constraint '(...args: any) => any'.
// Type 'Function' provides no match for the signature '(...args: any): any'.</code></pre><p>这里需要关注一下 Parameters 传入 never 和 any 的情况,知道即可。</p><h2><strong>七. ConstructorParameters<Type></strong></h2><p>把构造函数的参数类型作为一个元组类型返回。我们已经知道如何获取一个函数的参数类型,即<a href="#" title="9.">Parameters</a>。那么如果我们要获取构造函数的参数类型,那么首先先要判断出哪一个是构造函数,然后获取参数类型即可,思路是这么个思路,那么应该怎么实现呢?</p><h3><strong>7.1 源码解析</strong></h3><pre><code>/**
* Obtain the parameters of a constructor function type in a tuple
*/
type ConstructorParameters<T extends abstract new (...args: any) => any> = T extends abstract new (...args: infer P) => any ? P : never;</code></pre><p>这里有两个需要关注的点:</p><ul><li>abstract关键字修饰的函数叫抽象方法,而<strong>抽象方法只能出现在抽象类中</strong></li><li>new (...args: any) => any,这就是对构造函数的定义</li></ul><p>所以,<strong>ConstructorParameters</strong>工具类型只是针对抽象类来发挥效果的,never和any我们不需要关心。</p><h3><strong>7.2 实战用法</strong></h3><p>这里结合一个具体的例子,深入了解一下,就以 ErrorConstructor 为例:</p><pre><code>interface ErrorConstructor {
new(message?: string): Error;
(message?: string): Error;
readonly prototype: Error;
}
type T0 = ConstructorParameters<ErrorConstructor>;</code></pre><p>这里 ConstructorParameters 是如何处理的呢?<strong>其实就是对 ErrorConstructor 的三条属性逐一代入 T extends abstract new (...args: infer P) => any ? P : never; ,满足条件的就返回参数类型 P。</strong></p><pre><code>type T0 = string</code></pre><h2><strong>八. ReturnType<Type></strong></h2><p>获取函数的返回值类型。结合我们在<a href="#" title="10.">Parameters</a>获取函数参数类型的实现,可以很容易的自己实现出这个工具类型。</p><h3><strong>8.1 源码解析</strong></h3><pre><code>/**
* Obtain the return type of a function type
*/
type ReturnType<T extends (...args: any) => any> = T extends (...args: any) => infer R ? R : any;</code></pre><p>和 Parameters<Type> 实现的唯一的区别就是 infer 的位置从参数位置跑到了返回值位置。这还是很好理解的。</p><p><strong>注意返回值类型不是元组类型,因为在 Parameters<Type> 中,args是一个数组,所以返回的是一个元组类型,而函数的返回值可以是任意类型的。</strong></p><h3><strong>8.2 实战用法</strong></h3><pre><code>declare function f1(): { a: number; b: string };
type T0 = ReturnType<() => string>;
// type T0 = string
type T1 = ReturnType<(s: string) => void>;
// type T1 = void
type T2 = ReturnType<<T>() => T>;
// type T2 = unknown
type T3 = ReturnType<<T extends U, U extends number[]>() => T>;
// type T3 = number[]
type T4 = ReturnType<typeof f1>;
// type T4 = {a: number;b: string;}
type T5 = ReturnType<any>;
// type T5 = any
type T6 = ReturnType<never>;
// type T6 = never
type T7 = ReturnType<string>;
// 类型“string”不满足约束“(...args: any) => any”。ts(2344)
type T8 = ReturnType<Function>;
// 类型“Function”不满足约束“(...args: any) => any”。
// 类型“Function”提供的内容与签名“(...args: any): any”不匹配。ts(2344)</code></pre><h2><strong>九. InstanceType<Type></strong></h2><p>获取构造函数实例的返回类型。也就是对构造函数调用<code>new</code>操作符后的返回值类型。我们已经知道了如何获取构造函数参数的类型,所以很方便的就可以推导出如何获取实例的类型。</p><h3><strong>9.1 源码解析</strong></h3><pre><code>/**
* Obtain the return type of a constructor function type
*/
type InstanceType<T extends abstract new (...args: any) => any> = T extends abstract new (...args: any) => infer R ? R : any;</code></pre><p>也是把 infer 的位置从参数位置移到了返回值位置。</p><h3><strong>9.2 实战用法</strong></h3><p>这里我们继续以 FunctionConstructor 为例:</p><pre><code>interface FunctionConstructor {
/**
* Creates a new function.
* @param args A list of arguments the function accepts.
*/
new(...args: string[]): Function;
(...args: string[]): Function;
readonly prototype: Function;
}
type T0 = InstanceType<FunctionConstructor>;
</code></pre><p>结合 new(...args: string[]): Function; 可知:</p><pre><code>type T0 = Function;</code></pre><p>更多情况可以在 typescript playground 中自己尝试一下。</p><h2><strong>十. 下回预告</strong></h2><p>在下一篇《玩转TypeScript工具类型(下)》里,将会包括如下内容,敬请期待:</p><p>ThisParameterType<Type></p><p>OmitThisParameter<Type></p><p>ThisType<Type></p><h2><strong>快捷跳转</strong></h2><ul><li><a href="https://link.segmentfault.com/?enc=v9%2BmfrI9jrcn5NfdQpuzug%3D%3D.TBa5KFqR549U8xxrTHj5QT7QXZdJLfgU4dhO7ZWHKjPbNXhuCGrxLtwVPjlN18K2" rel="nofollow" title="https://juejin.cn/post/6987258682463649805">玩转TypeScript工具类型(上)</a></li></ul>
玩转TypeScript工具类型(上)
https://segmentfault.com/a/1190000040653014
2021-09-08T18:29:51+08:00
2021-09-08T18:29:51+08:00
有道AI情报局
https://segmentfault.com/u/youdaotec
14
<p><img src="/img/remote/1460000040653016" alt="首图.gif" title="首图.gif"><br><strong>联系我们</strong>:<strong>有道技术团队助手</strong>:ydtech01 / <strong>邮箱</strong>:<a href="mailto:ydtech@rd.netease.com">mailto:ydtech@rd.netease.com</a></p><p>随着前端项目的规模不断变大,多人协同开发已经成为了前端开发的标配,随之而来的就是 TypeScript 被越来越多的项目所使用,这种变化并不是对技术的盲目追求,而是业务驱动下的技术进步,TypeScript 通过对原生 JavaScript 提供强类型加持,在很大程度上提升了代码质量,大大降低了多人协同场景下不同模块接口相互调用可能出现的隐性 bug。本系列分享来源于我日常开发中对 TypeScript 工具类型的一些学习和使用体会,本系列文章分为上中下三篇,通过本系列分享,希望可以达到如下几个目的:</p><ul><li>从源码的角度理解每一种工具类型的实现机制</li><li>通过一两个简单的例子了解每一种工具类型的基本用法</li><li>与此同时加深对 TypeScript 的理解</li><li>最终实现可以在实际工作中举一反三</li></ul><h2>一、 Partial<Type>:可选</h2><h3>1.1 源码解读</h3><p>Partial<Type>类型的源码如下所示:</p><pre><code class="js">type Partial<T> = {
[P in keyof T]?: T[P];
};</code></pre><p>这里需要关注四个点:</p><ul><li><code><T></code>:这是目标类型,也就是我们要做处理的类型,类型不确定,所以用泛型 T 表示</li><li><code>[P in keyof T]</code> :keyof T返回 T 类型的所有键组成的一个类型,in 可以按照js中的for..in遍历去理解,后续对keyof有更详细的说明</li><li><code>?</code>:可选,把返回类型的所有属性都转为可选类型</li><li>返回的是一个新类型,这个新类型来源于 T,并且和 T 在属性上有一种<strong>继承关系</strong>,在第2小节对<code>Required<Type></code>的说明中会验证这一点</li></ul><p>基于对源码的理解,就可以很好的理解<code>Partial<Type></code>类型的作用就是<strong>返回一个新类型,这个新类型和目标类型 T 拥有相同的属性,但所有属性都是可选的</strong>。</p><h3>1.2 实战用法</h3><p>场景说明:在实际的业务开发中,经常会遇到需要对一个数据对象做<strong>整体或者局部更新</strong>的需求,这里就可以用到<code>Partial<Type></code></p><pre><code class="js">interface DataModel {
name: string
age: number
address: string
}
let store: DataModel = {
name: '',
age: 0,
address: ''
}
function updateStore (
store: DataModel,
payload: Partial<DataModel>
):DataModel {
return {
...store,
...payload
}
}
store = updateStore(store, {
name: 'lpp',
age: 18
})</code></pre><blockquote><a href="https://link.segmentfault.com/?enc=l%2Fc%2FvPzsfMVOu622SfKBZA%3D%3D.HDycLeyUxUqrgCXOlnayaJ97PgSiRlKAUXc43Vm3Oi6LAt30M8VruSQktJdBVh8ihrBTiUEBIxV9XN0zr9YyA89W2Xw%2FTxy5vJQ1liH%2FH0dKhWX4XqZMmcO8Ivb8KX%2FqlvyyFPESX%2F8d2or9ViSyDammRdHJPEX%2BS4GKqOaQjkF9QgP9BZPn3U8E9gOGX7c6qsZUD6DAI0SxzwgEEDMvzyagADTOygX8NHSZxdAJbDmNHBNKBmtZScNX%2F1VNYMRowspabrgcrq8VzU4XZAyZ%2B%2Bn4glfkoXPWxRMNFRWyCUB1DJNi0OnLiHI5iHv%2BUXhUKGGAofRcipaitGPQv6VBoMkmiTkGdBQ8G7FjwVAjEqGePEfYs4kP7K81kSuBgiMjvDIupgItG5RXISmrelxQJyK%2F1JdEdpBe%2B%2F433ZElNbzoYWaiXPJP99HLG0LPkrAGvn0RJYWn7vpqgA0TIWoQHQ%3D%3D" rel="nofollow">亲手试一试</a></blockquote><h3>1.3 补充</h3><p>这里增加一个对 keyof 的说明,通过一段代码来理解一下:</p><pre><code class="js">interface Person {
name: string;
age: number;
location: string;
}
type K1 = keyof Person; // "name" | "age" | "location"
type K2 = keyof Person[]; // "length" | "push" | "pop" | "concat" | ...
type K3 = keyof { [x: string]: Person }; // string
const person: Person = {
name: '',
age: 0,
location: ''
}
type k11 = keyof typeof person; // "name" | "age" | "location"</code></pre><blockquote><p><a href="https://link.segmentfault.com/?enc=DBl6nA2tmjN1U0ndUdBEtA%3D%3D.wqYziH5AHL6FxAU27Bw7%2BWmRy04mZ%2BCtMGQX%2FGSLsfirnvPxeLdwFjpezii2tht0hmM8tVTJrLcPRWSCoqA5Rw%3D%3D" rel="nofollow">参考一:官方文档说明</a></p><p><a href="https://link.segmentfault.com/?enc=rmGkl1QBhdGnNYjlQcvMmw%3D%3D.e2r1B%2BgM7cVmpFcXCDZ4WEfmy3piX6nIwqw2gUV82YEHvMLkZPYDXudMPvX0dTSgnEypnD%2Bf1ZJfBCBoB7%2BRnBwIUmtsxGZ07gIF1jth0KkhMwKtTZQmFp8GpWX1Tly%2B" rel="nofollow">参考二:What does “keyof typeof” mean in TypeScript?</a></p></blockquote><h2>二、 Required <Type>:必要的</h2><h3>2.1 源码解读</h3><pre><code class="js">type Required<T> = {
[P in keyof T]-?: T[P];
};</code></pre><p>这个类型的作用就是把类型 T 中的所有属性都转为<strong>可选属性</strong>。</p><p>这里源码中使用了一个 -? 来标注属性为必填的属性,那么这个 -? 是否是必须的呢?因为我们理解的可选属性是用 ? 明确标识的才是可选的,如果我们把-? 去掉,为什么就无法实现 Required 的效果了呢?我们先写一个 MyRequired<T>,如下所示:</p><pre><code class="js">type MyRequired<T> = {
[P in keyof T]: T[P];
};
interface Props {
a?: number;
b?: string;
}
const obj: MyRequired<Props> = {
a: 5
};</code></pre><blockquote><a href="https://link.segmentfault.com/?enc=wlQdC9voG8BvvMYUM3kzew%3D%3D.xU1zJLHx5pheKjV3%2FqNhzkaMaycVlp9kfMCyZiJj%2BnlU%2B%2BIhauTqyUQp%2Bql%2FDgGSgkrckb1%2FgnKRMfMq96DEyf4d1Xx15S9OXbm8dTx4ZF1uTHLqUwdbDNt1XHdK8%2Fqr60eQmrB6zjLuKAfx6v8U1P6KhzjNY6NalvS4zjoj2m1drqsbHIwLf1dhM9%2BFudgL%2F3ihW3DQG8X1Hy3G%2BWG0%2BW8vb0g3q%2FWUP82PRVpI5QX%2FhqN%2FLlnGqrZGa9uGhUSkggLc1iH5HM%2BXPuTgbqhphDUxyALfvtslQ4hjywLviz%2FyYJ49vtqZHHD5gfBYjEfD" rel="nofollow">亲手试一试</a></blockquote><p>上面的代码是没有类型错误的,为什么呢?因为如果只是[P in keyof T] ,P 中的属性会<strong>保留它自身在 T中的可选性</strong>。即之前如果是必填的,在新类型中还是必填的,如果是选填的同理。有点类似一种<code>“继承关系”</code>。<br>所以使用 -? 来清除可选性实现 Required 。<strong>当然,+? 也是有效的,所以参考Partial<T>,可知 +? 的 +`是可以省略的</strong>。</p><h3>2.2 实战用法</h3><p>Required<T>会将传入的T类型的所有属性都转为必要的。所以最常见的用法就是做诸如此类的转换,但是如果只是想把 T 类型的<strong>某些属性转为必填并把这些属性返回成一个新类型</strong>:</p><pre><code class="js">interface Props {
a?: string
b?: string
c?: string
}
// 仅保留b,c属性并转为必填
type NewProps1 = Required<Pick<Props, 'b' | 'c'>>
// 需要保留Props的所有属性,但是b,c需要必填
type NewProps2 = Partial<Props> & Required<Pick<Props, 'b' | 'c'>>
const obj: NewProps2 = {
b: '1',
c: '2'
}</code></pre><blockquote><a href="https://link.segmentfault.com/?enc=RaZCY9%2BCwmlsAracsBhUIg%3D%3D.dFdBZIu5FP9Mtw6C%2F7WwGwx8fiAcU1k3VxBKJ8UosVFDHwafovtEJNL5tpENVCb8E72Nidgi5frNxkpmr0cfehbEJKR9MxZVcwXV9PtnPW6aXWKE4pMrQtv3BTmWqCvk4EppiNih%2BWzNvuLml9f2Oncd1wW59rBmbq44cZeCILhgxzp%2FKrqvPwlh0ZAVRYwVkiQOBWUYh%2BCaqSYNYRwHvSvL8pM4PBPpi9yb94AkUO0Nm9Zz4xPSZgWzsA6SmJC1NjNijHEiEupPE9sn5nifAFbBqUDQ3h%2BjWsgNeOK99oZIfZhCLiu4RuHh3duaTWBc70z1eJnTuIOidasTZPduFt%2FQNbULQAjeruUXcWO%2Fss01r6JRhHQaN18ALzAGN2sDSIW3%2BqPYSQZYi4VMXTMkVYT%2BSpv5Q2o06PkUYdjhFO354VQyOTOo79%2BbgFgSvKJ%2Fh37g7mqghBR4hOl9Kaxc%2BPDi2HaqciPSYSf2y95%2BVDoYGYpUoVTrKqEvjJhxvmGm" rel="nofollow">亲手试一试</a></blockquote><h2>三、 ReadOnly<Type>:只读</h2><h3>3.1 源码解读</h3><pre><code class="js">type Readonly<T> = {
readonly [P in keyof T]: T[P];
};</code></pre><p>将类型 T 中包含的属性设置为readonly,并返回一个新类型。<br>readonly,顾名思义表示只读,初始化后就不能再修改值。这个类型可以配合 javascript 的 const 关键字实现<strong>引用类型属性值为常量</strong>的目的。</p><p>这个类型有一个局限性,就是只能设置子属性为只读,如果子属性还是一个引用类型,那对孙属性是不起作用的,那么有没有什么办法可以实现递归把所有引用都设置成只读的呢?</p><p>欢迎大家留言讨论。</p><h3>3.2 实战用法</h3><pre><code class="js">interface Person {
name: string
age: number
}
const person: Readonly<Person> = {
name: 'lpp',
age: 18
}
person.age = 20; // 无法分配到 "age" ,因为它是只读属性。ts(2540)</code></pre><p>如果没有 readonly ,在 javascript 中,如果给 const 变量赋值唯一个引用类型,比如一个对象,是可以修改属性值的,不能修改的是变量中存储的引用,如果要实现对象属性值的不可变,在 javascript 中可以使用 Object.freeze 。</p><pre><code class="js">function freeze<Type>(obj: Type): Readonly<Type>;</code></pre><h2>四、 Record<Keys, Type>:记录</h2><p>该工具类型会构造一个类型,这个类型的键的类型是 Keys,值的类型 Type 。</p><h3>4.1 源码解读</h3><pre><code class="js">type Record<K extends keyof any, T> = {
[P in K]: T;
};</code></pre><p>这里我们看到对 K 的类型定义使用的是 keyof any。这里 keyof any string | number | symbol,如下所示:</p><pre><code class="js">type a = keyof any;
// 等价于
type a = string | number | symbol;</code></pre><h3>4.2 实战用法</h3><pre><code class="js">// 简单的限定键和值的类型
type Obj1 = Record<string, string>
// 基于其他类型生成新的类型
type FruitTypes = 'apple' | 'banana' | 'pear'
interface FruitInfo {
name: FruitTypes
price: number
}
type Fruits = Partial<Record<FruitTypes, FruitInfo>>
const fruits: Fruits = {
apple: {
name: 'apple',
price: 10
}
}</code></pre><blockquote><a href="https://link.segmentfault.com/?enc=BQd13utBG%2FQhmKLGX9NkAA%3D%3D.h5UzD6z5%2F051aVAKzME3mvaAnxa8eDlmA4ypNZx0RdcuAHdW0YimuDPEcYg1%2FuFP%2F42SiLawHxYTKa%2F3UNZkt93tAQlEPwXDc%2B7wJTLh4hE68inCjTBRdd2U%2Bmb1rUw3y5zIHts1bj819eUtqaBEocwvbK%2BRvhWUBNzdsIOsYJpsksWlYoBlUaMN%2BaUbe7vKHuBrau0L4sF%2BB2sO1M5tvKahhYUR5eZ1MxsDVudO42LNPR3ILMnh1eq8QDzdglc%2BgeyRHjcvizzFv98XsTZ3kH6vLq%2By5s6wiCGkg9wnhoWKbiyIEHYg8tXtZ7k7MeJCnuhmuKm3L338ZBzkCfAEqU8WT%2BkuBcM416YmrP1hcm1vQUT5iMwWDt%2BxVbA0VomUqYoH8wq0gO8c03gjyAuyJFIhSxcXKz7ULnOKsa%2Buyc6wS8qHv%2FUUU9G0XQSJncD5jKJoAHCRXZDEnB3%2BsPO1yoq8VglIzByZ4IwQtfZCY4INgM9ibjnG1zMXnwcPWB1D19xAiWvwP19eO2IsLw1uEQ%3D%3D" rel="nofollow">亲手试一试</a></blockquote><h2>五、Pick<Type, Keys>:挑取</h2><p>从类型 Type 中,挑选一组属性组成一个新的类型返回。这组属性由 Keys 限定, Keys 是字符串或者字符串并集。</p><h3> 5.1 源码解读</h3><pre><code class="js">type Pick<T, K extends keyof T> = {
[P in K]: T[P];
};</code></pre><p><strong> K extends keyof</strong> ,表示 K 需要是 keyof T 的子集。返回的类型的键需要满足[P in K],值类型满足T[P]。</p><h3>5.2 实战用法</h3><pre><code class="js">interface Person {
name: string
age: number
id: string
}
// 幼儿没有id
type Toddler = Pick<Person, 'name' | 'age'></code></pre><h2>六、 Omit<Type, Keys>:忽略</h2><p>构造一个类型,这个类型包含类型 Type中除了 Keys 之外的其余属性。 Keys是一个字符串或者字符串并集。</p><h3>6.1 源码解读</h3><pre><code class="js">type Omit<T, K extends keyof any> = Pick<T, Exclude<keyof T, K>>;
type Exclude<T, U> = T extends U ? never : T;</code></pre><p>因为 Omit 依赖了 Exclude ,所以这里把 Exclude 的类型源码一起贴在这里。<br>Exclude<T, U>的作用是从 T 中排除那些可以分配给 U 的类型。</p><p>这里先不讲 Exclude 是如何实现的,只需要知道功能即可。</p><p>所以可以把 Exclude<keyof T, K> 看作是一个反选,选出了 T 中那些不包含在 K 中的属性,然后在用 Pick ,就实现了 Omit 。</p><h3>6.2 实战用法</h3><pre><code class="js">interface Person {
name: string
age: number
id: string
work: string
address: string
girlFriend: number
}
// 没工作的人
type PersonNoWork = Omit<Person, 'work'>
// 没住址的人
type PersonNoAddress = Omit<Person, 'address'>
// 没女朋友的人
type PersonNoGirlFriend =Omit<Person, 'girlFriend'</code></pre><h2> 七、练习题</h2><p>如何实现一个工具类型 SelectRequired<T, K in keyof T> ,实现如下效果:</p><pre><code class="js">interface Props {
a?: string
b?: string
c?: string
d: string
}
type NewProps = SelectRequired<Props, 'b' | 'c'>; // { a?: string, b: string, c: string, d: string }</code></pre><blockquote><a href="https://link.segmentfault.com/?enc=BnVCvRr3Hp5k0ClzbJ5efQ%3D%3D.KcQyrndV%2FFmj3D9R3DZBnNwb8B9w7L0YEqyUwlGCo0V2VG%2FNTdU7HZ82bg8tcdche1XMv7QIDpJ9xELQb%2BNs4CIvJcSxZI3jDPZEHgr1bQe1uWML5kZNjEUdw%2B06PXNhRWI3DV6LTfdU0Q9r4dgitqwgTsWUJi%2BKNNMxxj5NUcFF1dfQSLQ3x7ZWT%2FHRjdq7HQkREHFdpqPnjWcl4zTHbZi7PESDVDfVPbyE8R4xuw4zxqP4Pblep8x9EP8XCnMaT%2Bj2cE3tKu8gynuRo6PZAafBLz61ldlozcpNRRBoBCIPZVaNZB01ObyG2f%2F31ruGxS97%2F8vJT9Qn8rKBg1yinAVWihBkTTykUn7ODReg0Mnk5Urg0ym9qVtsB3bWzEb1pq%2FuQsJj9FdBm8ECyhTqwuwwnpvd65z1SygviMXwjGGmP839cMACdjm6XprsL8WT7ekZH1PR3AbCCNY3G2zunw%3D%3D" rel="nofollow">答案点这里</a></blockquote><h2>八、下回预告</h2><p>在下一篇《玩转TypeScript工具类型(中)》里,将会包括如下内容,敬请期待:</p><ul><li><strong>必读</strong>:extends条件运算符</li><li><strong>Exclude</strong><Type, ExcludeUnion></li><li><strong>Extract</strong><Type, Union></li><li><strong>NonNullable</strong></li><li><strong>必读</strong>:tuple type元组类型</li><li><strong>Parameters</strong></li><li><strong>ConstructorParameters</strong></li><li><strong>ReturnType</strong></li><li><strong>InstanceType</strong></li></ul>
有道词典 Flutter 架构与应用
https://segmentfault.com/a/1190000040615348
2021-09-01T18:13:05+08:00
2021-09-01T18:13:05+08:00
有道AI情报局
https://segmentfault.com/u/youdaotec
5
<p><img src="/img/remote/1460000040615350" alt="首图.gif" title="首图.gif"></p><p><strong>联系我们</strong>:<strong>有道技术团队助手</strong>:ydtech01 / <strong>邮箱</strong>:<a href="mailto:ydtech@rd.netease.com">mailto:ydtech@rd.netease.com</a></p><p>在 18 年 Flutter 发布正式版 1.0 版本以来,有道 Luna 团队保持持续的关注,在不少业务上进行大量的尝试,Flutter 本身统一 Skia 引擎带来的跨平台特性和一致的体验,AOT 下高性能,JIT 下热重载带来提高开发效率等特性,都让人们保持极大的热情和持续的投入,其生态社区也在快速增长。</p><p>从实际表现上来看,整个技术栈设计很好。上层 Flutter Framework 引入 Widget/LayerTree 等概念自己实现了界面描述框架,下层 Flutter Engine 把 LayerTree 用 OpenGL 渲染成用户界面。 </p><p>长期来看,用 Flutter 来替代 Native ,实现双端代码统一,节约人力开发,也是我们持续探索的方向。</p><h2><strong>一、前言</strong></h2><h3><strong>1.1 词典业务尝试</strong></h3><p>我们使用 Flutter 在有道词典去年的3月份、7月份分别上线了单词本和听力模考业务,现在是Flutter 1.12版本以下是业务展示</p><ol><li>单词本:</li></ol><p><img src="/img/remote/1460000040615351" alt="图1.jpg" title="图1.jpg"></p><ol start="2"><li>听力模考:</li></ol><p><img src="/img/bVcUy34" alt="" title=""></p><p>我们在较为独立的新业务上进行大胆尝试,新技术难免会有问题,但是还是要勇于尝试。</p><h2><strong>二、Flutter 基础简介</strong></h2><h3><strong>2.1 Dart</strong></h3><ul><li><strong>Dart单线程模型</strong></li></ul><p>Dart 和 JavaScript 都是单线程模型,运行机制很相似,Dart 官方提供了一张<strong>运行原理</strong>图:</p><p><img src="/img/remote/1460000040615352" alt="图3.png" title="图3.png"></p><p>Dart 在单线程中是以消息循环机制来运行的,其中包含两个任务队列,一个是“微任务队列” <strong>microtask queue</strong>,另一个叫做“事件队列” <strong>event queue</strong>。从图中可以发现,微任务队列的执行优先级高于事件队列。</p><p>其中 event queue:负责处理I/O事件、绘制事件、手势事件、接收其他 isolate 消息等外部事件;microtask queue:可以自己向 isolate 内部添加事件,事件的优先级比 event queue 高。</p><p>事件队列模型过程:</p><ol><li>先检查 MicroTask 队列是否为空,非空则先执行 MicroTask 队列中的MicroTask</li><li>一个 MicroTask 执行完后,检查有没有下一个 MicroTask ,直到 MicroTask 队列为空,才去执行 Event 队列</li><li>在 Evnet 队列取出一个事件处理完后,再次返回第一步,去检查 MicroTask 队列是否为空</li></ol><p>在事件循环中,当某个任务发生异常并没有被捕获时,程序并不会退出,而直接导致的结果是当前任务的后续代码就不会被执行了,也就是说一个任务中的异常是不会影响其它任务执行的。</p><p>异常捕获上传至统计崩溃平台也是应用这个模型,后面会讲到</p><h3>2.2 Flutter Widget</h3><p>介绍完 Dart,我们再看下 Flutter Widget。在 Flutter 中一切皆为 Widget,通过使用 Widget 可以实现页面整体布局、文本展示、图片展示、手势操作、事件响应等。</p><p><img src="/img/remote/1460000040615353" alt="图4.png" title="图4.png"></p><h4><strong>2.2.1 StatelessWidget</strong></h4><p>StatelessWidget 是一个<strong>没有状态的 widget</strong> ——没有要管理的内部状态。它通过构建一系列其他小部件来更加具体地描述用户界面,从而描述用户界面的一部分。当我们的页面不依赖 Widget 对象本身中的配置信息以及BuildContext 时,就可以用到无状态组件。例如当我们只需要显示一段文字时。实际上 Icon、Divider、Dialog、Text 等都是 StatelessWidget 的子类。</p><h4><strong>2.2.2 StatefulWidget</strong></h4><p>StatefulWidget 是<strong>可变状态的 widget</strong>。使用 setState 方法管理 StatefulWidget 的状态的改变。调用setState 通知 Flutter 框架某个状态发生了变化,Flutter 会重新运行 build 方法,应用程序变可以显示最新的状态。 状态是在构建 widget 的时候,widget 可以同步读取的信息,而这些状态会发生变化。要确保在状态改变的时候即使通知 widget 进行动态更改,就需要用到 StatefulWidget。例如一个计数器,我们点击按钮就要让数字加一。在 Flutter 中,Checkbox、FadeImage 等都是有状态组件。</p><p>StatefulWidget的生命周期大致可分为三个阶段:</p><ul><li><strong>初始化</strong>:插入渲染树,这一阶段涉及的生命周期函数主要有 createState、initState、didChangeDependencies 和 build。</li><li><strong>运行中</strong>:在渲染树中存在,这一阶段涉及的生命周期函数主要有 didUpdateWidge t和build。</li><li><strong>销毁</strong>:从渲染树中移除,此阶段涉及的生命周期函数主要有 deactivate 和 dispose。</li></ul><p>具体的声明周期调用过程如下:</p><p><img src="/img/remote/1460000040615354" alt="图5.png" title="图5.png"></p><h4><strong>2.2.3 StatefulWidget 和 StatelessWidget 的实用场景</strong></h4><p>在 Flutter 中,组件和页面数据变化是通过 State 驱动的,对于有交互的页面或组件可以继承 StatefulWidget,静态组件或页面可以继承 StatelessWidget。</p><p><strong>StatelessWidget</strong> 没有内部状态,Icon、 IconButton 和 Text 都是无状态 widget, 他们都是 StatelessWidget 的子类。</p><p><strong>StatefulWidget</strong> 是动态的. 用户可以和其交互或者可以随时间改变 (也许是数据改变导致的UI更新)。Checkbox、 Radio、Slider, InkWell、Form、TextField 都是 StatefulWidget, 他们都是 StatefulWidget 的子类。</p><p>使用 StatefulWidget 还是 StatelessWidget 的<strong>判断依据</strong>:</p><ul><li>如果用户与widget交互,widget 会发生变化,那么它就是有状态的.</li><li>widget 的状态(state)是一些可以更改的值, 如一个 slider 滑动条的当前值或 checkbox 是否被选中.</li><li>widget的状态保存在一个State对象中, 它和widget的布局显示分离。</li><li>当widget状态改变时, 调用 setState(), 告诉框架去重绘widget.</li></ul><h2><strong>三、混合开发 - 整体框架</strong></h2><p>开发之初我们考虑两个问题:</p><ul><li><strong>场景1</strong>:a,b两个业务线,都要在 flutter 工程里面开发业务?</li><li><strong>场景2</strong>:M app 有 flutter 工程,这个时候我们 N app 里的 flutter 工程要嵌入到 M app 里我们怎么办?</li></ul><p>起初我们希望生成多个产物进行嵌入,通过 Flutter 的线下会议探讨发现这个思路是比较后期的事情,但是也得到了另一个思路将我们的业务进行“<strong>下沉</strong>”,下沉到同一个工程里面进行业务区分,引入组件化的概念进行实践;</p><h3><strong>3.1 支持多团队开发</strong> </h3><p>Flutter 工程中,通常有以下4种工程类型,下面分别简单概述下:</p><p><strong>1. Flutter Application</strong>:标准的 Flutter App 工程,包含标准的 Dart 层与 Native 平台层</p><p><strong>2. Flutter Module</strong>:Flutter 组件工程,仅包含 Dart 层实现,Native 平台层子工程为通过Flutter自动生成的隐藏工程</p><p><strong>3. Flutter Plugin</strong>:Flutter 平台插件工程,包含 Dart 层与 Native 平台层的实现</p><p><strong>4. Flutter Package</strong></p><p>Flutter纯Dart插件工程,仅包含Dart层的实现,往往定义一些公共Widget</p><p>Flutter 工程之间的依赖管理是通过 Pub 来管理的,依赖的产物是直接源码依赖,这种依赖方式和 IOS 中的 Pod 有点像,都可以进行依赖库版本号的区间限定与Git远程依赖path等,其中具体声明依赖是在 pubspec.yaml 文件中,其中的依赖编写是基于 YAML 语法, YAML 是一个专门用来编写文件配置的语言,下面是依赖示例:</p><pre><code class="js">vibration:
git:
url: https://github.com/YoudaoMobile/flutter_vibration.git
ref: 'task/youdao'</code></pre><pre><code class="js">flutter_jsbridge_builder:
path: ../../Common/flutter_jsbridge_builder</code></pre><p>所以,通过 <strong>Flutter Plugin / </strong>Flutter Package<strong> +</strong> <strong>Pub</strong> 达到解耦的目的</p><p><img src="/img/remote/1460000040615355" alt="图6.jpg" title="图6.jpg"></p><p>以 <strong>Flutter Plugin / Flutter Package</strong>为模块开发,原则上我们将工程分为壳工程,业务组件,基础组件;依赖关系为壳工程->业务组件->基础组件,不能依赖倒置,同层之间不能相互引用。</p><p> </p><h3><strong>3.2 基础组件沉淀</strong></h3><p>通过以组件化形式进行开发,通过各个团队业务的不断迭代,逐步沉淀出一套CommonUI的基础Widget组件,方便其他业务和团队扩展使用。</p><ul><li><strong>button</strong>工具:YDLabelButton,YDRaisedButton </li><li><strong>字体</strong>工具:YDFontWeight,YDText,YDHtmlText</li><li><strong>遮罩</strong>工具:YDMaskView</li><li><strong>loading</strong>工具:YDDefaultLoadingView,YDLoadingView</li><li><strong>圆角</strong>工具:CornerDecoration</li><li><strong>模态弹出</strong>工具:YDCupertinoModalPopupRoute</li><li><strong>点击弹窗</strong>工具:YDCoordinateTap</li><li><strong>帧序列动画</strong>工具:YDSimpleFrameAniImage</li><li><p>…</p><p>YDRaisedButton,YDLabelButton是我们统一遵守有道UI准则自定义的一套button内容<br>YDText集成了英文屏幕取词,以及解决中日韩同时展示在界面字体展示异常的问题<br>YDHtmlText我们集成了基于html标签展示进行深层定制,来实现富文本的效果<br>YDSimpleFrameAniImage 帧动画播放组件,解决了单纯的图片第一次循环播放会闪烁等问题的播放动画组件<br>YDCupertinoModalPopupRoute 仿照新的ios模态弹出的效果,支持随手滑动消失的交互方式<br>....</p></li><li><img src="/img/bVcUy6p" alt="" title=""></li></ul><h3><strong>3.3 元编程</strong></h3><p>我们在开发过程发现我们的bridge的内容大多数是相同的,只不过是形参,函数名不同罢了,所以我们打算引入source_gen,来生成bridge层的代码,这样也带来两个好处,一是防止手误,带来的不必要的bug,二是将代码统一</p><p>source_gen主要提处理dart源码,可以通过注解生成代码。</p><p>大致的流程是通过 source_gen 一个 _Builder ,_Builder 需要生成器 Generator ,之后通过 Generator 去生成代码。</p><p>总结一下,在 Flutter 中应用注解以及生成代码仅需一下几个步骤:</p><p><strong>1.依赖 </strong></p><pre><code class="js">dev_dependencies:
source_gen: ^0.9.0</code></pre><p><strong>2.创建注解</strong></p><pre><code class="js">class JSBridgeModule {
final String moduleName;
final List<String> enumTypeName;
const JSBridgeModule({this.moduleName : "app", this.enumTypeName : const []});
}
</code></pre><p><strong>3.创建生成器</strong></p><pre><code class="js">class JSBridgeImplGenerator
extends GeneratorForAnnotation<JSBridgeModule> {
JSBridgeImplGenerator() {}
@override
Iterable<String> generateForAnnotatedElement (
Element element, ConstantReader annotation, BuildStep buildStep) {
if (element is! ClassElement) {
final name = element.name;
throw InvalidGenerationSourceError('Generator cannot target `$name`.',
return _generate(classElement, moduleName, enumTypeName: checkEnumTypeName);
}
}</code></pre><p><strong>4.创建Builder</strong></p><pre><code class="js">Builder getJSBridgeImpGeneratorBuilder(BuilderOptions options) {
return SharedPartBuilder();}</code></pre><p><strong>5.编写配置文件</strong></p><p>在项目根目录创建 build.yaml 文件,配置各项参数</p><pre><code class="js">builders:
JSBridgeImpGeneratorBuilder:
import: "package:flutter_jsbridge_builder/builder.dart"
builder_factories: ["getJSBridgeImpGeneratorBuilder"]
build_extensions: {".dart": ["flutter_jsbridge_builder.g.part"]}
auto_apply: dependents
build_to: cache
applies_builders: ["source_gen|combining_builder"]</code></pre><p>这样就为我们输出一份模板代码提供了实现的可能</p><h3><strong>3.4 资源管理</strong></h3><p>众所周知,flutter图片等资源管理方面,还是处在手动管理阶段,费时费力,所以推荐一款网易严选团队开发的flr插件,flr配合通过AndroidStudio插件,将用于帮助Flutter开发者在修改项目资源后,可以自动为资源添加声明到 <code>pubspec.yaml</code> 以及生成集中在一起的资源路径文件,Flutter开发者可以在代码中通过资源ID函数的方式应用资源。</p><p>通过建立起一个自动化的服务来监听和管理资源变化,之后将变化的资源同步到 <code>pubspec.yaml</code> 和对应的资源文件当中,也支持文本,字体资源,后续我们也和flr的团队支持黑暗模式的计划。</p><p>地址:<a href="https://link.segmentfault.com/?enc=ktipweFYOeKlJLx1RkV1DQ%3D%3D.ztvVO9K5KS50rx9QZRm6wbqav2GY%2BKbzKyWnLONXP8d46Yk%2F0%2BiLsCer%2BSIeRg5nY2u%2F4UF8Ue21MfVdCGliUWccZR3kVXhCzbzsIPk9ewB6oLNWdyOAmW0pjb6NxFi4" rel="nofollow">https://github.com/Fly-Mix/fl...</a></p><h3><strong>3.5 异常捕获上传</strong></h3><p>在flutter简介里面我们介绍了dart的线程模型</p><p>事件队列模型过程:</p><ol><li>先检查 MicroTask 队列是否为空,非空则先执行 MicroTask 队列中的MicroTask</li><li>一个 MicroTask 执行完后,检查有没有下一个 MicroTask ,直到 MicroTask 队列为空,才去执行 Event 队列</li><li>在 Evnet 队列取出一个事件处理完后,再次返回第一步,去检查 MicroTask 队列是否为空</li></ol><p>在事件循环中,当某个任务发生异常并没有被捕获时,程序并不会退出,而直接导致的结果是当前任务的后续代码就不会被执行了,也就是说一个任务中的异常是不会影响其它任务执行的。</p><p>Flutter 框架为我们在很多关键的方法进行了异常捕获。在发生异常时,错误是通过 FlutterError.reportError 方法上报的,其中 onError 是 FlutterError 的一个静态属性,我们重写onError就可以捕获异常了;但是还有一些异步异常是需要我们通过Zone方法来捕获的,整理代码如下:</p><p><img src="/img/remote/1460000040615356" alt="图8.png" title="图8.png"></p><h2><strong>四、产物介绍</strong></h2><h3><strong>4.1 介绍</strong></h3><p>1.12是个分水岭,在这之前安卓打包方式有所不同,并且iOS官方也提供一些命令也来支持打不同的包</p><ul><li><strong>iOS产物组成</strong></li></ul><p><img src="/img/remote/1460000040615357" alt="图9.jpg" title="图9.jpg"></p><p>通过 flutter build ios --release 来得到产物,然后 flutter-plugins 里面记录各种 plugin 的位置 copy 过来,放在.symlinks文件夹下 </p><p><strong>app.framework</strong>:代码数据段+图片<br>flutter.framework:engine+channel+...<br><strong>FlutterPluginRegistrant</strong>:源码,一些flutter自身的bridge<br>podhelper.rd:通过flutter-plugins里的bridge列表循环的将bridge填到pod中,在宿主工程通过pod引入</p><ul><li><strong>android产物组成 flutter1.0</strong></li></ul><p><img src="/img/remote/1460000040615358" alt="图10.png" title="图10.png"></p><p>进入 flutter 工程的 .android 文件夹执行 ./gradlew assembleRelease 就会打出一个 flutter-release.aar 的包,但是还有 path_provider,share_preference,audioplayer 等官方插件我们也需要 copy出来,这里我们发现 flutter 工程目录下面有.flutter-plugins 这个文件,这个文件记录着你当前 flutter所使用的官方插件的文件位置,我们通过 shell 读取文件位置,找到对应的 aar 集中到一起。</p><ul><li><strong>android flutter 1.12以后</strong></li></ul><p><img src="/img/remote/1460000040615359" alt="图11.png" title="图11.png"></p><p>flutter 1.12打包执行flutter build aar --no-debug –-no-profile来得到.</p><p> </p><h2><strong>4.2 打包问题汇总</strong></h2><p>在flutter1.0版本进入android文件夹执行./gradlew assembleRelease会得到aar产物,但是此时的aar嵌入进去run起来会报错,错误信息是缺少一个.dat文件,根据官方的issue和对源码的思考,讨论结果是里assets下面少一个flutter_assets文件内容,事实上在io/flutter.jar可以看到,但是flutter还是会去assets文件夹下去找,导致嵌入Android失败。解决办法,从apk里copy一份flutter_asset放到aar里</p><p>在flutter 1.12版本官方提供aar产物命令,但是工程中引入官方库(shared_preferences)的时候会执行命令失败,原因是第三方会带上macos和web的package,但是这个package不带android文件的内容,解决办法:通过修改官方sdk对其android文件夹进行兼容。</p><p> </p><h2><strong>4.3 打包机问题汇总</strong></h2><p>在打包机配置完flutter环境,需要在Jenkins的节点配置将flutter path添加到PATH当中,否则flutter 命令执行失败,以及ios 打包flutter build ios --release会因为code sign没有权限的问题失败,尽量用flutter build ios --release --no-codesign来得到环境</p><p> </p><h2><strong>五、遇到的问题</strong></h2><h3><strong>5. 1ios端存在的问题</strong></h3><h4><strong>5.1.1 混合栈 boost出现的问题</strong></h4><p>首先感谢咸鱼团队,提供了混合栈的一种方案,我们从flutter1.9升级到1.12过程中,遇到不少的问题和麻烦。</p><p><strong>1.生命周期多次回调</strong></p><p>在1.9的版本中,ContainerLifeCycle.Appear 方法会回调两次,导致依赖生命周期操作重复,在 ios 这边是在 viewdidappear 的时候会发通过 channel 发 didShowPageContainer 的消息,调用 nativeContainerDidShow,然后在 TransitionBuilder 的方法再去调用一次.</p><p>onPageStart 然后再去调用 nativeContainerDidShow,就会导致两次触发,android 也是在 onAppear 的方法上重复上述的操作。</p><blockquote><strong>解决办法</strong>就是去掉其中一个。</blockquote><p><strong>2.升级1.12之后,切前后台的crash问题</strong></p><p>这个问题版本有很多,得考虑业务场景,我们这里是先模态出一个NavigationViewController,然后在这个NavigationViewController基础上进行push和pop操作,然后我们在全局提供一个回到模态之前ViewController的操作。在全局回退的过程中,我们清掉了native的栈,然后在native的任意vc,切前后台后crash。但是在1.9版本时并没有发现此类问题。crash的原因是在1.12的版本中 FlutterEngine自身加了surfaceUpdated的操作,当你整个退出后没有正确的处理,导致FlutterEngine认为你的页面上还存在着Flutter页面,进行刷新创建工作,就crash了。当然这个是我们这个业务场景总结出的crash的原因,据说还有其他版本crash问题,欢迎其他朋友补充。</p><p>解决办法是在全局回退的过程中循环调用close方法将栈里的vc退出。</p><h4><strong>5.1.2 多语言显示异常</strong></h4><p>当界面同时显示在韩语/日语 与中文时,界面展示异常</p><p>官方issue:<a href="https://link.segmentfault.com/?enc=Qfho13e3WH5zZ9nrrV4iLA%3D%3D.X7KPW78Y3vq%2BbdYZiOi7s%2FV468xu4nhf9zeTME5GdzWFS8gMXYrgIKkmy9xEeKpq" rel="nofollow">https://github.com/flutter/fl...</a></p><p>解决方式有三种:</p><ol><li>增加字体 ttf ,全局指定改字体显示。</li><li>TextStyle 属性指定</li></ol><pre><code>fontFamilyFallback: ["PingFang SC", "Heiti SC"]</code></pre><p>可以封装成一个widget</p><ol start="3"><li>修改主题下所有 TextTheme 的 fontFamilyFallback</li></ol><pre><code class="js">getThemeData() {
var themeData = ThemeData(
primarySwatch: primarySwatch
);
var result = themeData.copyWith(
textTheme: confirmTextTheme(themeData.textTheme),
accentTextTheme: confirmTextTheme(themeData.accentTextTheme),
primaryTextTheme: confirmTextTheme(themeData.primaryTextTheme),
);
return result;
}
/// 处理 ios 上,同页面出现韩文和简体中文,导致的显示字体异常
confirmTextTheme(TextTheme textTheme) {
getCopyTextStyle(TextStyle textStyle) {
return textStyle.copyWith(fontFamilyFallback: ["PingFang SC", "Heiti SC"]);
}
return textTheme.copyWith(
display4: getCopyTextStyle(textTheme.display4),
display3: getCopyTextStyle(textTheme.display3),
display2: getCopyTextStyle(textTheme.display2),
display1: getCopyTextStyle(textTheme.display1),
headline: getCopyTextStyle(textTheme.headline),
title: getCopyTextStyle(textTheme.title),
subhead: getCopyTextStyle(textTheme.subhead),
body2: getCopyTextStyle(textTheme.body2),
body1: getCopyTextStyle(textTheme.body1),
caption: getCopyTextStyle(textTheme.caption),
button: getCopyTextStyle(textTheme.button),
subtitle: getCopyTextStyle(textTheme.subtitle),
overline: getCopyTextStyle(textTheme.overline),
);
}</code></pre><h3><strong>5.2 双端存在的问题</strong></h3><p>flutter pub get失败</p><p>Flutter 项目在引用第三库时,在pub会选择使用 git 引用,如:</p><pre><code>flutter_boost:
git:
url: 'https://github.com/YoudaoMobile/flutter_boost.git'
ref: 'youdao_0.0.8'</code></pre><p>会报pub get fail的问题</p><p>在下载包的过程中出现问题,下次再拉包的时候,在 <code>.pub_cache</code> 内的 <code>git</code> 可能是空目录,导致 <code>flutter packages get</code> 的时候异常。</p><p>所以你需要清除掉 <code>.pub_cache</code> 内的 <code>git</code> 的异常目录或者执行flutter cache repair ,之后重新执行 <code>flutter packages get</code> 。</p><h3><strong>5.3 channel 通信</strong></h3><p>Flutter定义了三种不同类型的Channel,它们分别是</p><ul><li>BasicMessageChannel:用于传递字符串和半结构化的信息。</li><li><li>MethodChannel:用于传递方法调用(method invocation)。</li><li><li>EventChannel: 用于数据流(event streams)的通信。</li></ul><p>其中channel有个很重要的变量codec;Codec官方定义了两种Codec:MessageCodec和MethodCodec</p><p>其中MessageCodec有4种不同的种类:</p><p>BinaryCodec;StringCodec;JSONMessageCodec;StandardMessageCodec</p><p>起初我们使用MethodChannel来建立通信,但是使用过程中遇到大内存的传递耗时很长的问题,我们通过一系列的实验和官方文档的指引,当需要传递大内存数据块时,使用BasicMessageChannel以及BinaryCodec可以解决问题。</p><p>以下是实验内容:</p><blockquote><p>实验机型:iphone 7 ios 13.7系统</p><p>实验数据:开发者可以自行模拟1M-2M左右的数据进行测试,由于涉及到真实数据 这里就不放出来了。</p><p>实验结果:</p><p><img src="/img/remote/1460000040615360" alt="实验数据.png" title="实验数据.png"></p><p>从实验结果上看,传输效率最优的是BinaryCodec,当然选择什么样的code根据项目的需求来定才是最合理的</p><p>BinaryCodec>StringCodec>StandardMessageCodec>JSONMessageCodec</p></blockquote><h3><strong>5.4 长列表优化</strong></h3><h4><strong>5.4.1 列表内容项长短不一</strong></h4><p>当我们实现类似上面的页面,item 高度不一的思路肯定是类似以下的代码,但是当我们达到一定的数量级的情况下,发现内存占用的十分严重,导致有些需求就实现不了,比如说支持滚动条快速定位;究其因CustomScrollView 初始化就加载了很多的 widget</p><pre><code class="js">List<Widget> list = [];
for (int i = 0; i < 10000; i ++) {
list.add(SliverToBoxAdapter(child: Container(height:30,color: Colors.red,),));
list.add(SliverFixedExtentList(delegate: SliverChildBuilderDelegate((context,index){
return Container(child: Text(index.toString()),);
},childCount: 50), itemExtent: 50)
);
}
CustomScrollView(slivers: list,);</code></pre><p>但是假如都是同样的 itemExtent,滚动效率,内存表现都是良好的;因为事先告诉好高度,而不是依赖 widget自身的 layout 计算效率就高了很多, 比如说以下的代码:</p><pre><code class="js">List<Widget> list = [];
list.add(SliverFixedExtentList(delegate: SliverChildBuilderDelegate((context,index){
return Container(child: Text(index.toString()),);
},childCount: 20000), itemExtent: 50)
);
CustomScrollView(slivers: list,);</code></pre><p>如何<strong>两者兼得</strong>呢?</p><p>我们决定自定义SliverFixedExtentList, SliverFixedExtentList返回的RenderObject是RenderSliverFixedExtentBoxAdaptor,我们将RenderSliverFixedExtentBoxAdaptor重新设计下。</p><p>RenderSliverFixedExtentBoxAdaptor原先设计的思路是通过scrolloffset除以itemExtent计算出当前的index(itemExtent是写死的所以直接除),SliverConstraints可以拿到他的remainingCacheExtent也就是cacheExtent加上滚动可见区域,也就可以拿到lastIndex,在滚动的过程中不断的释放和创建。我们改写的思路如下:</p><blockquote><p>1.SliverFixedExtentList在createRenderObject和updateRenderObject的时候将每个元素的位置重新计算缓存。</p><p>2.然后重写performLayout方法,通过全局的first和last索引拿到元素的位置和scrolloffset进行比较,得到新的first和last索引,不断的调整。</p><p>3.将计算好的约束布局传入子布局。</p></blockquote><p>大致的思路就是这样,接口层面我们设计成这个样子,以下是调用示例:</p><pre><code class="js">YDSliverFixedExtentList(
delegate: SliverChildBuilderDelegate((context, int index) {
if (index > wordList.length - 1){
return Container(color: Colors.transparent,);
}
YDWBListBaseModel model = wordList[index];
if (model is YDWBListHeaderModel) {
return buildSusWidget(model.title);
} else if (model is YDWBListItemModel){
return buildListSingleItem(index, wordList[index], onMoveTap, onDeleteTap);
} else{
return Container();
}
},
childCount: wordList.length + 1,
),
itemHeightDelegate: (index){
if (index > wordList.length - 1){
return 60;
}
var model = wordList[index];
if (model is YDWBListHeaderModel) {
return kItemHeaderHeight;
} else if (model is YDWBListItemModel) {
return kItemHeight;
} else {
return 60;
}
},
itemIndexDelegate: (startIndex, endIndex){
firstIndex = startIndex;
lastIndex = endIndex;
},</code></pre><h4><strong>5.4.2 如何获取当前展示列表索引</strong></h4><p>ios开发都知道,我们的TableView是有代理来知道我们当前页面展示的Cell的索引,但是在Flutter里我们怎么办呢?</p><ul><li><strong>思路1</strong>:给每个 item 加上 GlobalKey,然后放在 model 中,然后滚动的过程中利用去找循环遍历model 的 GlobalKey,通过 GlobalKey 找到对应的 RenderObject,RenderObject 存在着位置坐标等信息,通过此信息可以比较计算出 key 对应的 RenderObject 是否展示界面</li></ul><p>代码如下:</p><pre><code class="js">double y=model.key.currentContext.findRenderObject().getTransformTo(null).getTranslation().y;
double height=model.key.currentContext.findRenderObject().paintBounds.size.height;</code></pre><p>然后找到对应绑定的model</p><ul><li><strong>思路2</strong>:如果是实时获取展示的索引可能上述思路不太合适,可能每次都需要在存放 model 里的数组去找,当然也可以在思路1的基础上进行算法优化,暂存当前展示的 index,来做下次起始寻找的index,减少循环次数。</li></ul><p>不过,接下来介绍的是,另一种办法,改写 SliverChildBuilderDelegate,在SliverChildBuilderDelegate里面的didFinishLayout里会返回它的firstIndex和lastIndex,但是要注意此时返回的是加了cacheExtent的firstIndex,所以可能比实际展示的要小,所以可以结合思路一进行精确定位</p><pre><code class="js">class MySliverChildBuilderDelegate extends SliverChildBuilderDelegate {
final int markIndex;
MySliverChildBuilderDelegate(
this.markIndex,
Widget Function(BuildContext, int) builder, {
int childCount,
bool addAutomaticKeepAlives = true,
bool addRepaintBoundaries = true,
}) : super(builder,
childCount: childCount,
addAutomaticKeepAlives: addAutomaticKeepAlives,
addRepaintBoundaries: addRepaintBoundaries,
);
@override
void didFinishLayout(int firstIndex, int lastIndex) {
debugPrint('pre' + 'didFinishLayout firstIndex: $firstIndex, lastIndex: $lastIndex' + "markIndex" + markIndex.toString());
if (firstIndex == 0 && lastIndex == 0) {
return;
}
YDBaseListEvent.notifyIndexChange({"firstIndex": markIndex + firstIndex, "lastIndex": markIndex + lastIndex});
debugPrint(mark + 'didFinishLayout firstIndex: $firstIndex, lastIndex: $lastIndex' + "markIndex" + markIndex.toString());
}
}</code></pre><p>思路3:可以参考长列表优化,将SliverFixedExtentList改写暴露对应返回index的接口</p><p> </p><h2><strong>六、后续计划</strong> </h2><p>我们后续计划升级到flutter2.0,但是目前来看2.0还存在问题,如果2.0真的彻底的解决多引擎复用的问题,我们也会尝试去除boost的管理机制,根据<a href="https://link.segmentfault.com/?enc=47fu5Ks%2FPh4A7BovSny%2BHA%3D%3D.RsmaL0Q1HOmoW7h5ZApoxe3ioqyFwNf7Vf3iHkI6dtvZPO7xKuqseVJQmG0%2FhunFdKC%2FkYG1RkisleakgfxukQ%3D%3D" rel="nofollow">https://flutter.cn/posts/flut...</a>,在多个引擎复用的视频章节,于潇分析了多引擎复用的内存增长的问题,主要在</p><blockquote><ul><li>线程</li><li>GPU资源</li><li>Skia Context</li><li>字形</li><li>Dart Isolate</li></ul></blockquote><p>这五部分,每起一个 engine 之后就会起 3 个新的操作系统的线程,每个线程都是有成本的尤其是在ios上,在2.0版本上都合并在一起了;另一部分 GPU 资源,Skia Context 就包含了 opengl 的 context,metal context,metal buffer,shader program,skia program,为了提高使用启动将 GPU 资源和 Skia Context的内容做共享;字形的大小都会有缓冲的,假如不加以利用的话也造成一定的浪费;dart Isolate 事实上每次创建 engine 都会重新创建一个,2.0 版本也做了一个共享。</p><p>结果也是比较客观,优化的效果比较明显,10 次的启动不升反降,40M 变成了35M。有兴趣的可以试下,<a href="https://link.segmentfault.com/?enc=KHoUS%2BlDyZHQ%2B3wU2OaIFQ%3D%3D.tn%2BXJcMIHpd3kFBAANIsoheZMJhI0SWCTvjKNaHUeLCqTZGZhq2KVuFU3xLoG1yAUuQdeKIYWxRmR9W6yZgbxA%3D%3D" rel="nofollow">https://github.com/flutter/sa...</a>,但是对于 flutter 团队来说 2.0 版本只是解决了内存问题,还存在其他的问题,主要是以下几方面:</p><blockquote><ul><li>只支持AOT,不支持debug</li><li>ios IOSurface卸载,fluttervc没有解除但是又被覆盖了的话,它的metal layer没有释放IOSurface,会影响混合栈的场景,目前的办法是覆盖的时候需要手动把flutterview 去掉</li><li>不支持数据共享</li><li>不支持内存共享</li><li>platform View 不支持</li><li>只支持一个snapshot</li></ul></blockquote><h2><strong>七、结束语</strong></h2><p>我们在单词本和听力等模块进行 flutter 落地的探索,在前期实践过程中,碰到了很多问题,但总体来说还处于可控的状态;前期把各种困难都解决后,后面业务再此基础上进行开发会顺畅很多,效率会提升很多,这个也是 flutter 期望带给我们的一次开发,多端运行。但是另一方面希望开发者们在落地过程中,更为慎重些,多多实践,提前发现提前解决,毕竟存在处理不好的情况,还需要推动官方或者生态提供更好的解决办法。</p><p>未来期望 flutter 以及社区在平台一致性以及混合栈,内存,键盘,音视频等具体问题上持续发力,我们也会进一步的探索 flutter 在业务上更多实现的可能。感谢观看。</p><p>以上内容仅代表个人观点,如果内容或者实验数据存在疑问和问题,欢迎大家批评指正,一起学习,一起成长。</p><ul><li>本内容仅代表个人观点,不代表网易,仅供内部分享传播,不允许以任何形式外泄,否则追究法律责任。</li></ul>
你真的了解二叉树吗?(手撕算法篇)
https://segmentfault.com/a/1190000040574003
2021-08-25T18:16:12+08:00
2021-08-25T18:16:12+08:00
有道AI情报局
https://segmentfault.com/u/youdaotec
2
<p><img src="/img/bVcUofz" alt="" title=""></p><p><strong>联系我们</strong>:<strong>有道技术团队助手</strong>:ydtech01 / <strong>邮箱</strong>:<a href="mailto:ydtech@rd.netease.com">ydtech@rd.netease.com</a></p><h2><strong>前言</strong></h2><p>本文属于系列文章《你真的了解二叉树吗》的第二部分——手撕算法篇。</p><p>如果你还没有看过第一部分<a href="http://segmentfault.com/a/1190000040539924">《你真的了解二叉树吗(树形结构基础篇)》</a>的话,强烈建议先看一下第一部分的内容,这样你在解题时会更加如虎添翼。很多第一篇里面已经讲过的内容,在这里将不再赘述。</p><h2><strong>一、二叉树基础刷题部分</strong></h2><h3><strong>1.1 LeetCode 144 二叉树的前序遍历</strong></h3><h4><strong>解题思路</strong></h4><p>如果你有看过我上一篇文章<a href="https://segmentfault.com/a/1190000040539924">《你真的了解二叉树吗(树形结构基础篇)》</a>的话,应该已经知道了,我们树的遍历天生就适合使用<strong>递归实现</strong>。</p><p>此外,还讲了如何设计和实现一个递归函数,如果对着两点不太了解或没看过上一篇文章的同学,请先移步<a href="https://segmentfault.com/a/1190000040539924">《你真的了解二叉树吗(树形结构基础篇)》</a>补一下基础先,这里就不赘述了,直接讲思路吧:</p><p>首先,先设计一下我们的递归函数</p><ol><li>函数意义:前序遍历以 root 为根节点的二叉树</li><li>边界条件:root 为空时无需遍历,直接返回 root</li><li>递归过程:分别前序遍历左子树和前序遍历右子树</li></ol><h4><strong>代码演示</strong></h4><pre><code class="js">/*
* @lc app=leetcode.cn id=144 lang=typescript
*
* [144] 二叉树的前序遍历
*/
// @lc code=start
/**
* Definition for a binary tree node.
* class TreeNode {
* val: number
* left: TreeNode | null
* right: TreeNode | null
* constructor(val?: number, left?: TreeNode | null, right?: TreeNode | null) {
* this.val = (val===undefined ? 0 : val)
* this.left = (left===undefined ? null : left)
* this.right = (right===undefined ? null : right)
* }
* }
*/
function preorderTraversal(root: TreeNode | null, arr: number[]=[]): number[] {
// 判断边界条件
if(root) {
// 因为是前序遍历,所以先将根节点加入数组
arr.push(root.val);
// 递归遍历左子树和右子树
preorderTraversal(root.left, arr);
preorderTraversal(root.right, arr);
}
return arr;
};
// @lc code=end</code></pre><p>以上是常规的递归方式实现,我们也可以使用<strong>迭代的方式</strong>实现:</p><pre><code class="js">
/**
* Definition for a binary tree node.
* function TreeNode(val, left, right) {
* this.val = (val===undefined ? 0 : val)
* this.left = (left===undefined ? null : left)
* this.right = (right===undefined ? null : right)
* }
*/
/**
* @param {TreeNode} root
* @return {number[]}
*/
var preorderTraversal = function(root) {
// 用一个栈辅助完成迭代遍历
const stack = [];
// 结果数组
const res = [];
// 当前节点
let cur = root;
// 如果当前节点为空或者栈为空时结束循环
while(cur || stack.length>0) {
while(cur) {
// 因为是前序遍历,所以根节点先存放到结果数组
res.push(cur.val);
// 为了在我们走到左子树的底部时,能够回到根节点继续遍历右子树,因此,先将根节点存入栈中
stack.push(cur);
// 继续遍历左子树,直到遍历到叶子节点
cur = cur.left;
}
// 左子树遍历完了,我们需要遍历右子树了,首先我们先要从栈顶把最近的一个根节点弹出
cur = stack.pop();
// 遍历弹出的这个根节点的右子树
cur = cur.right;
}
return res;
};</code></pre><h3><strong>1.2 LeetCode 589 N叉树的前序遍历</strong></h3><h4><strong>解题思路</strong></h4><p>解题思路起始跟二叉树的解题思路一模一样,这里就不再赘述了。</p><p>首先,先设计一下我们的递归函数</p><ol><li>函数意义:前序遍历以 root 为根节点的 N 叉树</li><li>边界条件:root 为空时无需遍历,直接返回结果数组</li><li>递归过程:分别 root 下端的所有子树进行递归遍历</li></ol><h4>代码演示</h4><pre><code class="js">/*
* @lc app=leetcode.cn id=589 lang=typescript
*
* [589] N 叉树的前序遍历
*/
// @lc code=start
/**
* Definition for node.
* class Node {
* val: number
* children: Node[]
* constructor(val?: number) {
* this.val = (val===undefined ? 0 : val)
* this.children = []
* }
* }
*/
function preorder(root: Node | null, arr: number[] = []): number[] {
// 边界条件
if(!root) return arr;
// 前序遍历,先将根节点放入结果数组
arr.push(root.val);
// 循环递归调用所有的子节点
root.children.forEach(child => {
preorder(child, arr);
});
return arr;
};
// @lc code=end
</code></pre><h3><strong>1.3 LeetCode 226 翻转二叉树</strong></h3><h4><strong>解题思路</strong></h4><p>我们要反转一个二叉树,就要先将这个二叉树的左子树的子树和右子树的子树先反转,然后再反转根节点的左右子树。</p><p>这里利用到了 js 中的解构赋值的新特性进行高效快速反转。</p><p>首先,先设计一下我们的递归函数</p><ol><li>函数意义:反转以 root 为根节点的二叉树</li><li>边界条件:root 为空时无需反转,直接返回 root</li><li>递归过程:分别反转 root 的左子树和右子树</li></ol><h4><strong>代码演示</strong></h4><pre><code class="js">/*
* @lc app=leetcode.cn id=226 lang=typescript
*
* [226] 翻转二叉树
*/
// @lc code=start
/**
* Definition for a binary tree node.
* class TreeNode {
* val: number
* left: TreeNode | null
* right: TreeNode | null
* constructor(val?: number, left?: TreeNode | null, right?: TreeNode | null) {
* this.val = (val===undefined ? 0 : val)
* this.left = (left===undefined ? null : left)
* this.right = (right===undefined ? null : right)
* }
* }
*/
function invertTree(root: TreeNode | null): TreeNode | null {
// 边界条件,当root为空时直接返回
if(!root) return root;
// 先交换root的左右子树,这里我们利用js的解构赋值的新特性可以迅速的交换左右子树
[root.left, root.right] = [root.right, root.left];
// 分别递归左右子树
invertTree(root.left)
invertTree(root.right)
return root;
};
// @lc code=end
</code></pre><h3><strong>1.4 LeetCode 剑指Offer32. 从上到下打印二叉树Ⅱ</strong></h3><h4><strong>解题思路</strong></h4><p>这道题其实就是我们二叉树的层序遍历,将每一层的节点依次输出。这里可以使用一个技巧,增加一个变量 k 代表当前遍历到二叉树的第几层,默认为 0,即默认是二叉树的第一层。</p><p>首先,先设计一下我们的递归函数</p><ol><li>函数意义:将 root 为根节点端的左子树和右子树添加到当前层 k 所在的数组中</li><li>边界条件:root 为空时无需遍历,直接返回,直接返回结果数组</li><li>递归过程:分别递归遍历 root 的左子树和右子树,别忘了层数需要加 1</li></ol><h4><strong>代码演示</strong></h4><pre><code class="js">/**
* Definition for a binary tree node.
* class TreeNode {
* val: number
* left: TreeNode | null
* right: TreeNode | null
* constructor(val?: number, left?: TreeNode | null, right?: TreeNode | null) {
* this.val = (val===undefined ? 0 : val)
* this.left = (left===undefined ? null : left)
* this.right = (right===undefined ? null : right)
* }
* }
*/
function levelOrder(root: TreeNode | null,k=0, res: number[][] = []): number[][] {
// 边界条件,当root不存在时,直接返回结果数组
if(!root) return res;
// 如果结果数组的长度为k,说明我们准备遍历新的一层,但这层还没有数组来存放数据,所以要新建一个数组放到结果数组末尾
if(res.length===k) res.push([]);
// 将根节点的值放到第k位的数组中
res[k].push(root.val);
// 递归遍历左子树、右子树
levelOrder(root.left, k+1, res);
levelOrder(root.right, k+1, res);
return res;
};</code></pre><p>我们二叉树层序遍历的实现不仅可以使用递归,使用<strong>队列 + 迭代</strong>的方式也可以实现,思路也都差不多。</p><pre><code class="js">/**
* Definition for a binary tree node.
* class TreeNode {
* val: number
* left: TreeNode | null
* right: TreeNode | null
* constructor(val?: number, left?: TreeNode | null, right?: TreeNode | null) {
* this.val = (val===undefined ? 0 : val)
* this.left = (left===undefined ? null : left)
* this.right = (right===undefined ? null : right)
* }
* }
*/
function levelOrder(root: TreeNode | null, res: number[][]=[]): number[][] {
// 边界条件,root不存在是直接返回空数组
if(!root) return res;
// 用一个队列来存储节点,初始时根节点入队
const queue: TreeNode[] = [root];
// 用于记录当前遍历到了第几层
let k = 0;
while(queue.length!==0) {
// 当层数与结果数组长度一致时,说明遍历到了下一层,但没有一个数组用来存放下一层的数据,因此在结果数组最后放一个空数组
if(k===res.length) res.push([]);
// 将队列中的所有节点依次弹出,此时,队列中的所有节点都是当前层的节点,并且是从左到右排列的
let queueLen = queue.length;
// 注意,我们这行的终止条件是针对于本次循环的队列长度,如果在循环过程中动态加入队列中的元素,是不会循环到的,因此,可以在循环过程中
// 不断的将有子节点的元素的子节点压入队列而不用担心把下一层的数据也在本行输出
while(queueLen--) {
// 当根节点出队并将结果压入到第k层的数组中
const item = queue.shift();
res[k].push(item.val);
// 如果左子树存在则左子树入队
item.left && queue.push(item.left);
// 右子树存在,则右子树入队
item.right && queue.push(item.right);
}
// 本层所有节点处理完后,记得层数加一,准备遍历下一层
k++;
}
return res;
};</code></pre><h3><strong>1.5 LeetCode 107 二叉树的层序遍历Ⅱ</strong></h3><h4><strong>解题思路</strong></h4><p>这道题起始跟上一道题没什么区别,仅仅是将上一道题的结果数组反转,这个在 js 中实现极其简单,就不再赘述了。</p><h4><strong>代码演示</strong></h4><pre><code class="js">/*
* @lc app=leetcode.cn id=107 lang=typescript
*
* [107] 二叉树的层序遍历 II
*/
// @lc code=start
/**
* Definition for a binary tree node.
* class TreeNode {
* val: number
* left: TreeNode | null
* right: TreeNode | null
* constructor(val?: number, left?: TreeNode | null, right?: TreeNode | null) {
* this.val = (val===undefined ? 0 : val)
* this.left = (left===undefined ? null : left)
* this.right = (right===undefined ? null : right)
* }
* }
*/
function _lavelOrder(root: TreeNode|null, k: number, arr: number[][] = []): number[][] {
if(!root) return arr;
if(arr.length===k) arr.push([]);
arr[k].push(root.val);
_lavelOrder(root.left, k+1, arr);
_lavelOrder(root.right, k+1, arr);
return arr;
}
function levelOrderBottom(root: TreeNode | null): number[][] {
const res = _lavelOrder(root, 0);
// 使用双指针方式交换反转数组
for(let i=0,j=res.length-1;i<j;i++,j--) {
[res[i], res[j]] = [res[j], res[i]];
}
return res;
// 也可以直接使用数组自带的反转方法
// return res.reverse();
};
// @lc code=end
</code></pre><h3><strong>1.6 LeetCode 103 二叉树的锯齿形层序遍历</strong></h3><h4><strong>解题思路</strong></h4><p>这一题其实是上面两题思路的综合,我们只需要先按照正常的层序遍历输出得到结果数组,然后将结果数组中的奇数行进行翻转就可以了。</p><p>当然,我们可以用更好的方法,就是在进行层序遍历时,直接判断当前的层数 k 是奇数还是偶数,如果是奇数就往数组前面添加元素,否则往数组后面添加元素,这样就不需要额外再反转数组了。</p><h4><strong>代码演示</strong></h4><p><strong>方案一</strong>:先正常层序遍历,然后将奇数行数组反转</p><pre><code class="js">function _zigzagLevelOrder(root: TreeNode | null, k:number=0, res: number[][]=[]): number[][] {
if(!root) return res;
if(k===res.length) res.push([]);
res[k].push(root.val);
_zigzagLevelOrder(root.left, k + 1, res);
_zigzagLevelOrder(root.right, k + 1, res);
return res;
};
function zigzagLevelOrder(root: TreeNode | null): number[][] {
const res = _zigzagLevelOrder(root);
// 将奇数行反转
return res.map((item, index) => {
if(index&1) {
item = item.reverse();
}
return item;
})
}</code></pre><p><strong>方案二</strong>:在进行层序遍历时,如果k是奇数,则从数组前面开始添加元素,否则从后面添加元素</p><pre><code class="js">function _zigzagLevelOrder(root: TreeNode | null, k:number=0, res: number[][]=[]): number[][] {
if(!root) return res;
if(k===res.length) res.push([]);
(k&1)?res[k].unshift(root.val):res[k].push(root.val);
_zigzagLevelOrder(root.left, k + 1, res);
_zigzagLevelOrder(root.right, k + 1, res);
return res;
};
function zigzagLevelOrder(root: TreeNode | null): number[][] {
return _zigzagLevelOrder(root);
}</code></pre><h2><strong>二、二叉树进阶刷题部分</strong></h2><h3><strong>2.1 LeetCode 110 平衡二叉树</strong></h3><h4><strong>解题思路</strong></h4><blockquote>概念:对于任意一个子树而言,他的左右子树的高度差的绝对值不超过 1 的树被称为平衡二叉树。</blockquote><p>那么,依照题意,我们能想到的就是计算一棵树的左右子树的树高,然后看看他们差绝对值是否大于 1,为了仅使用一次递归就判断出这棵树是否是平衡树,我们可以在计算树高的时候,假设如果这棵树不是平衡树,也就是说左右子树树高差的绝对值是大于 1的时候,我们就直接返回一个负数,如 -1,这样,当我们的程序执行完后,只要判断<strong>树高是否小于 0</strong> 就可以知道当前的树是否是平衡树了。</p><p>首先,先设计一下我们的递归函数</p><ol><li>函数意义:在计算 root 为根节点的树的树高的同时,如果树不平衡,则返回一个负数</li><li>边界条件:当 root 为空时,树高为 0</li><li>递归过程:分别计算左右子树的树高,整个树的树高就是左右子树树高最大值加一</li></ol><h4><strong>代码演示</strong></h4><pre><code class="js">/*
* @lc app=leetcode.cn id=110 lang=typescript
*
* [110] 平衡二叉树
*/
// @lc code=start
/**
* Definition for a binary tree node.
* class TreeNode {
* val: number
* left: TreeNode | null
* right: TreeNode | null
* constructor(val?: number, left?: TreeNode | null, right?: TreeNode | null) {
* this.val = (val===undefined ? 0 : val)
* this.left = (left===undefined ? null : left)
* this.right = (right===undefined ? null : right)
* }
* }
*/
function getHeight(root: TreeNode | null): number {
// 边界条件,如果根节点为空,则树高0
if(!root) return 0;
// 分别计算左右子树的树高
const lH = getHeight(root.left);
const rH = getHeight(root.right);
// 假设:如果当前root的左右子树不平衡,则返回一个负数,如-1
// 除此之外,因为是递归程序,如果左右子树的子树的高小于0,那就说明他们的子树已经不平衡了,无需继续递归,直接返回-1
if(Math.abs(lH-rH)>1 || lH < 0 || rH < 0) return -1;
// 一个树的树高,应该是左右子树的树高最大值加一
return Math.max(lH, rH) + 1;
}
function isBalanced(root: TreeNode | null): boolean {
// 只要树高大于等于0,说明这颗树是平衡的
return getHeight(root) >= 0;
};
// @lc code=end
</code></pre><h3><strong>2.2 LeetCode 112 路径总和</strong></h3><h4><strong>解题思路</strong></h4><p>这道题的解题思路其实就是在我们不断往下找的时候,都把 targetNum 与当前的节点的值计算出一个差值,作为下一次递归时的 targetNum,这样,当我们找到叶子节点的时候,只要叶子节点端的值等于 targetNum 的话,就说明存在这条路径了。</p><p>首先,先设计一下我们的递归函数</p><ol><li>函数意义:在一个以 root 为根节点的二叉树中是否能够找到节点总和等于 targetNum 的值</li><li>边界条件:当 root 为空时,不可能存在,返回 false,此外,如果当前节点是叶子节点(没有左子树和右子树的节点),那么这个节点的值必须等于 targetNum</li><li>递归过程:递归判断左子树和右子树是否满足条件,需要注意的是,我们传入到递归函数中的 targetNum,需要与当前节点计算插值再传下去。</li></ol><h4><strong>代码演示</strong></h4><pre><code class="js">/*
* @lc app=leetcode.cn id=112 lang=typescript
*
* [112] 路径总和
*/
// @lc code=start
/**
* Definition for a binary tree node.
* class TreeNode {
* val: number
* left: TreeNode | null
* right: TreeNode | null
* constructor(val?: number, left?: TreeNode | null, right?: TreeNode | null) {
* this.val = (val===undefined ? 0 : val)
* this.left = (left===undefined ? null : left)
* this.right = (right===undefined ? null : right)
* }
* }
*/
function hasPathSum(root: TreeNode | null, targetSum: number): boolean {
// 边界条件,如果root为空时,直接返回false
if(!root) return false;
// 如果当前节点是叶子节点,那么这个节点的值必须等于targetSum
if(!root.left && !root.right) return root.val === targetSum;
// 在左子树中查找是否有满足条件的路径
if(root.left && hasPathSum(root.left, targetSum - root.val)) return true;
// 在右子树中查找是否有满足条件的路径
if(root.right && hasPathSum(root.right, targetSum - root.val)) return true;
// 都不满足则返回false
return false;
};
// @lc code=end
</code></pre><h3><strong>2.3 LeetCode 105 从前序与中序遍历序列构造二叉树</strong></h3><h4><strong>解题思路</strong></h4><p>如果有看过我写的上一篇文章<a href="https://segmentfault.com/a/1190000040539924">《你真的了解二叉树吗(树形结构基础篇)》</a>的同学应该是不会陌生了。上一篇文章中,已经带着大家在思维逻辑层面上分析过这题的解题思路了,但是没有一起编码实现,现在来还债了。</p><p>再来回顾一下大概的思路吧:</p><ol><li>先要找到根节点所在的位置</li><li>递归建立左子树</li><li>递归建立右子树</li><li>然后把左右子树挂在根节点上</li></ol><pre><code># 前序遍历输出结果
<1> 5 2 3 4
# 中序遍历输出结果
5 <1> 3 2 4
# 我们知道前序遍历的第一位就是我们二叉树的根节点,那么,我们根据这个根节点的数值在中序遍历中找到1所在的位置
# 这样,我们就知道,5是左子树,3 2 4是1的右子树序列,左子树无需继续处理,右子树可以直接递归处理得到最终的结果</code></pre><h4><strong>代码演示</strong></h4><pre><code class="js">/*
* @lc app=leetcode.cn id=105 lang=typescript
*
* [105] 从前序与中序遍历序列构造二叉树
*/
// @lc code=start
/**
* Definition for a binary tree node.
* class TreeNode {
* val: number
* left: TreeNode | null
* right: TreeNode | null
* constructor(val?: number, left?: TreeNode | null, right?: TreeNode | null) {
* this.val = (val===undefined ? 0 : val)
* this.left = (left===undefined ? null : left)
* this.right = (right===undefined ? null : right)
* }
* }
*/
function buildTree(preorder: number[], inorder: number[]): TreeNode | null {
if(preorder.length===0) return null;
// 用于记录根节点在中序遍历中的位置
let pos = 0;
// 找到根节点在中序遍历序列中的位置
while(inorder[pos] !== preorder[0]) ++pos;
// 分别用来存储拆分后的左右子树的前序和中序遍历序列
let l_pre = [], l_in = [], r_pre=[],r_in=[];
// 将左子树的前序遍历序列和中序遍历序列存起来
for(let i=0;i<pos;i++) {
l_pre.push(preorder[i+1]);
l_in.push(inorder[i]);
}
// 将右子树的前序遍历和中序遍历的序列存起来
for(let i=pos+1;i<preorder.length;i++) {
r_pre.push(preorder[i]);
r_in.push(inorder[i]);
}
// 构建二叉树
const node = new TreeNode(preorder[0], buildTree(l_pre, l_in), buildTree(r_pre, r_in));
return node;
};
// @lc code=end
</code></pre><h3><strong>2.4 LeetCode 222 完全二叉树的节点个数</strong></h3><h4><strong>解题思路</strong></h4><p>这道题就是让我们算一下一棵树有几个节点,我们只要知道这样的一个公式:<strong>总结点数量=左子树节点数量+右子树节点数量+根节点数量(1)</strong> 即可</p><h4><strong>代码演示</strong></h4><pre><code class="js">/*
* @lc app=leetcode.cn id=222 lang=typescript
*
* [222] 完全二叉树的节点个数
*/
// @lc code=start
/**
* Definition for a binary tree node.
* class TreeNode {
* val: number
* left: TreeNode | null
* right: TreeNode | null
* constructor(val?: number, left?: TreeNode | null, right?: TreeNode | null) {
* this.val = (val===undefined ? 0 : val)
* this.left = (left===undefined ? null : left)
* this.right = (right===undefined ? null : right)
* }
* }
*/
function countNodes(root: TreeNode | null): number {
// 边界条件,如果root不存在,则返回0
if(!root) return 0;
return countNodes(root.left) + countNodes(root.right) + 1;
};
// @lc code=end
</code></pre><h3><strong>2.5 LeetCode 剑指Offer 54 二叉搜索树的第k大的节点</strong></h3><h4><strong>解题思路</strong></h4><blockquote>二叉搜索树:右子树上所有的值都会大于根节点的值,左子树上的所有节点都会小于根节点的值。所以,二叉搜索树的中序遍历输出一定是一个有序数组。</blockquote><p>由于这是一颗二叉搜索树,那么左子树的树肯定小于根节点,右子树肯定大于根节点。<br>从这个特性我们就可以得出:</p><ol><li>如果要找第k大的树,并且k小于或者等于右子树节点数量的话,那我们可以直接到<strong>右子树中</strong>去查找,</li><li>如果k等于右子树节点数量加1,那么说明我们要找的数其实就是<strong>根节点</strong></li><li>如果不是上述两种情况,那么,我们就直接在左子树中查找,不过此时,因为我们已经排除了根节点和右子树了,就不再是找左子树第k大的节点了,我们应该<strong>减去根节点和右子树节点</strong>的数量</li></ol><h4><strong>代码演示</strong></h4><pre><code class="js">/**
* Definition for a binary tree node.
* class TreeNode {
* val: number
* left: TreeNode | null
* right: TreeNode | null
* constructor(val?: number, left?: TreeNode | null, right?: TreeNode | null) {
* this.val = (val===undefined ? 0 : val)
* this.left = (left===undefined ? null : left)
* this.right = (right===undefined ? null : right)
* }
* }
*/
// 计算节点数量
function getCount(root: TreeNode): number {
if(!root) return 0;
return getCount(root.left) + getCount(root.right) + 1;
}
// 由于这是一颗二叉搜索树,那么左子树的树肯定小于根节点,右子树肯定大于根节点
// 从这个特性我们就可以得出:
// 如果要找第k大的树,并且k小于或者等于右子树节点数量的话,那我们可以先到右子树中去查找,
// 如果k等于右子树节点数量加1,那么说明我们要找的数其实就是根节点
// 如果不是上述两种情况,那么,我们就直接在左子树中查找,不过此时,因为我们已经排除了根节点和右子树了,就不再是找左子树第k大的节点了,我们应该减去根节点和右子树节点的数量
function kthLargest(root: TreeNode | null, k: number): number {
// 获取右子树节点数量
const count_r = getCount(root.right);
// 如果k小于等于右子树节点数量,则在右子树上查找第k大的树
if(k<=count_r) return kthLargest(root.right, k);
// 如果k等于右子树节点数量加1,说明要找的树就是根节点
if(k===count_r+1) return root.val;
// 否则去左子树查找第k-(count_r+1)=k- count_r - 1 大的数
return kthLargest(root.left, k - count_r - 1);
};</code></pre><h3><strong>2.6 LeetCode 剑指Offer 26 树的子结构</strong></h3><h4><strong>解题思路</strong></h4><p>这道题我们其实就是在边界条件处理好之后,看一下 B 能否匹配一整颗 A 树,如果不能匹配,那么就看你一下 B 能不能匹配 A 的左子树或者其右子树,如果能够匹配上任意一个,就说明 B 是 A 的子结构。</p><h4><strong>代码演示</strong></h4><pre><code class="js">/**
* Definition for a binary tree node.
* class TreeNode {
* val: number
* left: TreeNode | null
* right: TreeNode | null
* constructor(val?: number, left?: TreeNode | null, right?: TreeNode | null) {
* this.val = (val===undefined ? 0 : val)
* this.left = (left===undefined ? null : left)
* this.right = (right===undefined ? null : right)
* }
* }
*/
// 递归匹配子树结构
function isMatch(a: TreeNode, b: TreeNode) {
// 如果b为空,肯定匹配,返回true
if(!b) return true;
// 如果a为空则不可能匹配,返回false
if(!a) return false;
// 如果a和b的值不一致,返回false
if(a.val !== b.val) return false;
// 分别递归匹配a、b的左子树和右子树
return isMatch(a.left, b.left) && isMatch(a.right, b.right);
}
function isSubStructure(A: TreeNode | null, B: TreeNode | null): boolean {
// 依据题意,如果B为空,则不是任何树的子结构,直接返回false
if(!B) return false;
// 若A为空,B不为空,则不可能匹配上,返回false
if(!A) return false;
// 递归判断A、B是否能够匹配,如果匹配上了,则返回true
if(isMatch(A, B)) return true;
// 上面的情况都不是,那么就分别去A的左子树和右子树看一下能否匹配,只要二者有一个匹配就算匹配成功
return isSubStructure(A.left, B) || isSubStructure(A.right, B);
};</code></pre><h3><strong>2.7 LeetCode 622 二叉树的最大宽度</strong></h3><h4><strong>解题思路</strong></h4><p>这道题在实现上确实是有一定的难度,我们可以借助上一篇文章<a href="https://segmentfault.com/a/1190000040539924">《你真的了解二叉树吗(树形结构基础篇)》</a>中讲过的完全二叉树的编号方式以及一个队列来辅助我们完成这个题目。在完全二叉树的编号中,假如当前节点的编号为i,那么他的左子树的编号为 2 <em> i,右子树的编号为 2 </em> i+1,不过这题中,有可能会因为编号过大导致整形数据溢出的情况,所以我们需要特殊处理。具体的解题思路,代码注释写得很清楚,直接看代码吧。</p><h4><strong>代码演示</strong></h4><pre><code class="js">/*
* @lc app=leetcode.cn id=662 lang=typescript
*
* [662] 二叉树最大宽度
*/
// @lc code=start
/**
* Definition for a binary tree node.
* class TreeNode {
* val: number
* left: TreeNode | null
* right: TreeNode | null
* constructor(val?: number, left?: TreeNode | null, right?: TreeNode | null) {
* this.val = (val===undefined ? 0 : val)
* this.left = (left===undefined ? null : left)
* this.right = (right===undefined ? null : right)
* }
* }
*/
// 为了方便存储节点和它对应编号,定义一个包装类
class Wrapper {
constructor(public node: TreeNode, public idx: number){
}
}
function widthOfBinaryTree(root: TreeNode | null): number {
// 用来存储结果
let res = 0;
// 定义一个包装类的队列,这个包装类可以存储节点和他对应的编号
const queue: Wrapper[] = [];
// 先将根节点和他的编号0加入队列
queue.push(new Wrapper(root, 0));
// 用于存储每一层有多少个节点
let rowSize;
while(rowSize = queue.length) {
// 设置当前层最小编号和最大编号的初始值都为父节点的编号
let lIdx = queue[0].idx, rIdx = queue[0].idx;
// 遍历当前层的每一个元素
while(rowSize--) {
// 先将队首元素出队
const { node, idx } = queue.shift();
// 当前层最大编号为父节点的编号
rIdx = idx;
// 当存在左子树时,使用左子树根节点和其对应的编号创建包装类,并加入到队列
// 其实这里,最难的就是这个编号如何界定。
// 我们有学过完全二叉树的编号方式,可以参考这种编号方式,
// 第i个节点的做子树根节点的编号为2*i,右子树的编号为2*i+1
// 所以,到了这里,我们就直接使用idx * 2和idx * 2 + 1代表左子树和右子树编号
// 如果我们这么提交的话,会遇到一个问题,就是当我们的树的节点足够大时,这个编号会超出
// 我们整数的范围,那么,我们要怎样在不影响程序运行结果的前提下,缩减这个编号,避免整数溢出呢
// 我们来想想,对于一个编号来说,其实最小和最大编号分别为6和7,其实跟为0和1是没有本质上的区别的
// 因为我们拿这个编号并没有实质的作用,只是为了用这个编号计算差值,7-6的差值跟1-0的差值是一样的
// 所以,我们这边直接让当前的编号鉴于父节点的最小编号lIdx,以此来缩减编号大小
node.left && queue.push(new Wrapper(node.left, (idx - lIdx) * 2));
node.right && queue.push(new Wrapper(node.right, (idx - lIdx) * 2 + 1));
}
// 一层循环结束后,我们只要计算上一个宽度,与当前层最大编号减去最小编号加1,然后两者取最大值找到最大宽即可
// 例如:当前层最小和最大的编号是:0和8,那么这一层其实有8-0+1=9个节点空间,也就是宽度为9.
res = Math.max(res, rIdx - lIdx + 1);
}
return res;
};
// @lc code=end</code></pre>
你真的了解二叉树吗?(树形结构基础篇)
https://segmentfault.com/a/1190000040539924
2021-08-19T09:37:06+08:00
2021-08-19T09:37:06+08:00
有道AI情报局
https://segmentfault.com/u/youdaotec
3
<p><img src="/img/remote/1460000040539926" alt="首图.gif" title="首图.gif"></p><p><strong>联系我们</strong>:<strong>有道技术团队助手</strong>:ydtech01 / <strong>邮箱</strong>:<a href="mailto:ydtech@rd.netease">mailto:ydtech@rd.netease</a>.com</p><h2><strong>前言</strong></h2><p>树形结构,尤其是二叉树,在我们平时开发过程中使用频率比较高,但之前对于树形结构没有一个比较系统全面的了解和认知,所以借此机会梳理一下。</p><p>本文属于《你真的了解二叉树吗》系列文章之一,主要介绍的是树形结构的基础,在看完这篇文章之后,如果想要更加熟练掌握二叉树的话,可以看另一篇《你真的了解二叉树吗(手撕算法篇)》(下周发布)。</p><h2><strong>一、树形结构基础</strong></h2><p>相较于<strong>链表</strong>每个节点只能唯一指向下一个节点(此处说的链表是单向链表),<strong>树</strong>则是每个节点可以有若干个子节点,因此,我们一个<strong>树形结构</strong>可以如下表示:</p><pre><code class="js">interface TreeNode {
data: any;
nodes: TreeNode[]
}</code></pre><h3><strong>1.1 树的度</strong></h3><blockquote><p>PS: 在图结构中,也有度的概念,分为出度和入度,如果把树看作是图的一部分的话,那么严格来说,树的度其实是出度。不过,在树形结构中,我们通常把度这个概念作为描述当前树节点有几个子节点。</p><p>即每个节点拥有几个孩子,因此,二叉树的度最大是 2,链表(可以看成只有一个孩子的树)的度最大是1。</p></blockquote><ul><li><strong>定理:</strong> 在一个二叉树中,度为 0 的节点比度为 2 的节点多1。</li><li><strong>证明:</strong> 假如一个树有 n 个节点,那么,这棵树肯定有 n-1 条边,也就是说,<strong>点的数量=边的数量+1</strong>(这个结论针对所有树形结构都适用,不仅仅是二叉树)如下图:</li></ul><p><img src="/img/remote/1460000040539927" alt="图1.png" title="图1.png"></p><p>这个棵树有 7 个节点,节点与节点之间的连线,也就是边只有 6 条。</p><p>那么,我们假设度为 0 的节点数量为 n0,度为 1的节点为数量 n1,度为 2 的节点数量为 n2,又因为是度为0的节点,说明他的边的数量 N0=0,度为 1 的节点边的数量为 N1=n1 <em> 1,度为 2 的节点边的数量为 N2=n2 </em> 2,那么总共的节点数量为:</p><blockquote><p><em>#由上可知,边数量的表达式如下</em></p><p>N0=0;</p><p>N1=1*n1;</p><p>N2=2*n2;</p><p><em>#由上可知,节点的数量=边的数量+1</em></p><p>n0 + n1 + n2 = N0 + N1 + N2 + 1;</p><p><em>#代入N0,N1,N2得:</em></p><p>n0 + n1 + n2 = 0 + n1 + 2*n2 + 1;</p><p><em>#化简得:</em></p><p>n0 = n2 +1;</p><p><em>#即度为0的节点数量永远比度为2的节点多一个</em></p></blockquote><p>由此,我们就证明了上面的定理,我们把这个定理换个描述或许更容易理解:</p><p><strong>在二叉树中,只要你知道了有多少个叶子节点,那么度为2的节点数量就是叶子节点的数量减1,反之,知道度为2的节点数量,那么叶子节点的数量就是度为2的节点数量加1。</strong></p><h3><strong>1.2 树的遍历</strong></h3><pre><code> 5
/ \
1 4
/ \
3 6</code></pre><p><img src="/img/remote/1460000040539928" alt="表格.jpg" title="表格.jpg"></p><h3><strong>1.3 树的遍历思想</strong></h3><p>树天生就是一个适合递归遍历的数据结构,因为每一次处理左子树和右子树的时候,其实就是递归遍历的过程。</p><ul><li><strong>前序遍历:</strong>「根节点」「递归遍历左子树的输出结果」「递归遍历右子树的输出结果」</li><li><strong>中序遍历:</strong>「递归遍历左子树的输出结果」「根节点」「递归遍历右子树的输出结果」</li><li><strong>后序遍历:</strong>「递归遍历左子树的输出结果」「递归遍历右子树的输出结果」 「根节点」</li></ul><h3><strong>1.4 思维发散</strong></h3><p>看到这里,有一些小伙伴可能会感觉似曾相识,是不是在哪里看过树相关的一些知识呢。其实在之前我们学习栈这个数据结构的时候,就有讨论过这个话题。</p><p>我们知道,栈天生适合用于表达式求值,那么,它在处理表达式求值的过程中,是怎样的一个逻辑结构呢?</p><p>如:3*(4+5) 这个表达式。其实,虽然我们在解答的时候,使用的是栈的思想,但实际上在逻辑层面,我们是在模拟一棵树的操作过程。不相信?那我们来看看:</p><pre><code> [ * ]
[ 3 ] [ + ]
[ 4 ] [ 5 ]</code></pre><p>上面,我们将这个表达式拆借成了一个树形结构,当我们表达式中遇到 () 时,说明里面的子表达式需要优先处理,那么,我们就把他看作是我们二叉树的一个子树。</p><p>我们都知道,树的遍历思想是<strong>递归遍历</strong>, 是由下往上逐层解决问题,这样,在递归调用的过程中,他就会先解决右子树的子问题,得到结果之后,再与左子树计算出来的结果进行最终运算得出最终结果。</p><h3><strong>1.5 还原二叉树</strong></h3><p>如果我们已知前序遍历结果、中序遍历结果、后续遍历结果<strong>三者中的任意两个</strong>,我们就能够完整的还原一颗二叉树。例如:</p><pre><code># 前序遍历输出结果
1 5 2 3 4
# 中序遍历输出结果
5 1 3 2 4</code></pre><p>上面是两种遍历方式的输出结果,我们知道,前序遍历的第一个节点一定是<strong>根节点</strong>,所以,此时,我们就已经知道,原二叉树的根节点为 1,接下来,我们拿这个 1 的节点到中序遍历的输出结果中,找到1的位置。</p><p>又因为中序遍历的输出结果是左根右,那么,我们不难知道,在 1 左边的就是原二叉树的左子树的中序遍历输出,在1右边的就是原二叉树的右子树的中序遍历输出。这样,我们就可以把中序遍历输出分成以下几块:</p><pre><code># 切割中序遍历结果
5 1 3 2 4
# 左子树 根 右子树
# 由上可知,我们左子树就已经出来了,就只有一个节点,就是5,但是右子树还是一个序列,那么我们继续往下走。
# 由上,我们已经知道了原二叉树的左子树序列、和右子树序列,那么,我们也来切割以下前序遍历结果
1 5 2 3 4
# 根 左子树 右子树
#切割了前序遍历结果之后,我们找到右子树的序列,他的序列的第一位就是右子树的根节点,也就是2,找到根节点后,就很简单了,重复上面的步骤,在二叉树的中序遍历结果的右子树中就能找到右子树的左子树和右子树分别为3和4,到此,我么就已经还原了这颗二叉树了
1
/ \
5 2
/ \
3 4</code></pre><p>上面只有5个节点的树,是不是很简单呢?接下来,我们再来一个稍微难一点的的思维题:</p><blockquote><p>已知10个节点的二叉树的前序遍历结果和中序遍历结果,还原这个二叉树。</p><p>前序遍历输出序列:1 2 4 9 5 6 10 3 7 8</p><p>中序遍历输出序列:4 9 2 10 6 5 1 3 8 7</p></blockquote><pre><code># 由2.可知,1是根节点,所以左子树序列:4 9 2 10 6 5 ;右子树序列:3 8 7
1.中序: 4 9 2 10 6 5 1 3 8 7
# 断言:1是根节点
2.前序: 1 2 4 9 5 6 10 3 7 8
# 由1.2可知,2是根节点,所以左子树序列:4 9 ;右子树序列:10 6 5
1.1中序: 4 9 2 10 6 5
# 断言:2是根节点
1.2前序: 2 4 9 5 6 10
# 由1.2.1可知,4是根节点,所以9是右子树
1.1.1中序: 4 9
# 断言:4是根节点
1.2.1前序: 4 9
# 由1.2.2可知,5是根节点,所以左子树序列为:10 6
1.1.2中序: 10 6 5
# 断言:5是根节点
1.2.2前序: 5 6 10
# 由1.2.2.2可知,6为根节点,所以10位左子树
1.1.2.1中序: 10 6
# 断言:6为根节点
1.2.2.2前序: 6 10
# 由2.2可知,3位根节点,所以右子树序列为:8 7
2.1中序: 3 8 7
# 断言:3为根节点
2.2前序: 3 7 8
# 由2.2.2可知,7为根节点,所以8为左子树
2.1.1中序: 8 7
# 断言:7为根节点
2.2.2前序: 7 8
# 最终二叉树长成这样
1
/ \
2 3
/ \ \
4 5 7
\ / /
9 6 8
/
10</code></pre><h3><strong>1.6 二叉树的常见分类</strong></h3><h4><strong>1.6.1 完全二叉树(Complete Binary Tree)</strong></h4><blockquote>只有在最后一层的<strong>右侧缺少节点</strong>的二叉树叫做完全二叉树,也就是说,完全二叉树的左侧是满的,只有右侧才允许有空节点。</blockquote><pre><code> 1
/ \
2 3
/ \ / \
4 5 6</code></pre><p>完全二叉树是一个非常优秀的一个数据结构,它有以下两个主要的特点,能够让我们在性能和程序实现上有更好的体验。</p><h5><strong>节点编号可计算</strong></h5><p>从上面的完全二叉树中,我们可以看出一个<strong>规律</strong>:</p><blockquote><p>编号为 n 的节点,他的左子树根节点的编号必定为 2n,他的右孩子的根节点的编号必定为 2n+1,如上图2的左子树根节点的编号为4,就是2 <em> 2=4。右子树根节点的编号为5,也就是2 </em> 2+1=5。</p><p>那么利用这个规律,我们可以干什么呢?</p><p>我们知道,普通的二叉树,除了存储数据用的数据域之外,还需要额外的存储空间用来存储左子树和右子树的指针,也就是指针域。如果我们能通过上面的规律直接计算出当前节点左子树和右子树根节点的编号,那是不是就不需要额外的存储空间去存储左右子树的存储地址了,当一个树足够大的时候,这可以给我们节省相当大的一个存储空间。</p></blockquote><p>上面通过计算来替代记录存储地址的方法,引申出一个我们在日常工作中经常会使用到的一个算法思想:<strong>记录式与计算式思想的转换</strong></p><ul><li><strong>记录式</strong>(节省时间,耗费空间,无需计算,直接取值,即:空间换时间): 把信息存起来,用到的时候取出来用。</li><li><strong>计算式</strong>(节省空间,耗费时间,无需存储,计算取值,即:时间换空间):通过计算得到的,如1+1=2中的2就是我们通过计算 1+1 这个表达式得到的结果。</li></ul><p>这两种方式各有各的优缺点,脱离问题本身比较这两种方式的优劣是没有意义的,我们应该结合具体问题,看使用哪种方式能给你带来更大的收益。</p><blockquote><p><strong>场景一</strong>:当<strong>内存空间有限</strong>,对计算时间要求不强时,如在一个内存较小的机器中运行一段程序,我们会选择计算式,用时间换空间。</p><p><strong>场景二</strong>:当我们<strong>内存空间足够大</strong>,并且对计算速度有要求时,如企业级应用服务器上运行实时计算数据时,我们会选择记录式,用空间换时间,因为一个企业级的应用,一般内存是足够大的,还可以动态扩容,这时候,时间所带来的效益就远大于空间所带来的的效益了。</p></blockquote><h5><strong>可使用连续的存储空间存储</strong></h5><p>除了节点编号(即节点地址)可计算这个特性外,完全二叉树由于他的编号是连续的,从上到下升序且连续的序列,因此,我们可以把完全二叉树存储在一个连续的存储区,如:数组中,数组下标为 0 的元素存放1号节点,为1的元素存放2号节点。</p><p>利用这个特性,我们在实现一个完全二叉树时,可以无需像实现普通二叉树一样单独定义一个结构,并分别定义<strong>数据域</strong>和<strong>指针域</strong>来分别存储数据和指针,我们完全可以使用一个数组直接存储数据,这也是我们完全二叉树最常见的表现形式。</p><p>我们来想象一下:你在程序中实现时用的是一维的线性结构,即数组来表示的,但在你的脑海里,应该要把它转化为二维的树形结构来思考问题,这也是一个相对高级的编程逻辑思维能力,让我们能够在脑海中将看到的数据结构“编译”成它真正运行时的模样。</p><p>当然,要有这样的能力,可不是一朝一夕的事情,需要经过大量的锻炼才能具备这种能力,至少,笔者写下此行的这一刻,是没办法达到这个境界的。</p><h4><strong>1.6.2 满二叉树(Full Binary Tree)</strong></h4><blockquote><p>没有度为1的节点的二叉树叫做满二叉树,即所有节点要么没有子节点,要么有两个子节点。</p><p>PS: 我们经常在网上看到很多文章博客上会把完美二叉树的定义放在满二叉树上,其实是错误的,完美二叉树的具体定义见下文。</p></blockquote><pre><code> 1
/ \
2 3
/ \ / \
4 5 6 7
/ \
8 9</code></pre><h4><strong>1.6.3 完美二叉树(Perfect Binary Tree)</strong></h4><blockquote>所有节点的度都为2。由此可以看出完美二叉树的定义还是与满二叉树有区别的。我们可以说完美二叉树是<strong>特殊的满二叉树</strong>。</blockquote><pre><code> 1
/ \
2 3
/ \ / \
4 5 6 7</code></pre><h2><strong>二、树结构深入理解</strong></h2><h3><strong>2.1 节点</strong></h3><p>树的节点代表<strong>一个集合</strong>,子节点就代表在父集合下互不相交的子集,这样说可能难以理解,那么,咱们来看下面的一个图:</p><pre><code> 5
/ \
2 3
# 上面的二叉树,5节点,我们可以把它当做是一个全集,而下面的两个子节点2和3则是这个全集下的两个互不相交的子集,两个子集相加应该等于全集</code></pre><p>由上图我们可以得出一个结论:</p><blockquote>树的一个节点代表一个集合,而子节点代表全集下面互不相交的子集,<strong>所有的子集相加能够得到全集</strong>。</blockquote><h3><strong>2.2 边</strong></h3><p>树的每一条边代表关系。</p><h2><strong>三、学习二叉树的作用</strong></h2><h3><strong>3.1应用于各种场景下的查找操作</strong></h3><p>由于二叉树结构包括天然递归结构、与二分思想完美契合的特性,使得二叉树及其各种变种结构极其适合在各种场景下进行高效的查找操作,我们计算机底层也有诸多设计时基于二叉树与二叉树变种结构的,便是由于其优秀的性能能够提供高效而稳定的查找效率。</p><h3><strong>3.2 有助于理解高级数据结构的基础</strong></h3><ul><li><p><strong>完全二叉树(维护集合最值的神兵利器)</strong></p><ul><li>堆</li><li>优先队列</li></ul></li><li><p><strong>多叉树/森林</strong></p><ul><li><p>解决字符串及相关转换问题的神兵利器</p><ul><li>字典树</li><li>AC自动机</li></ul></li><li><p>解决连通性问题的神兵利器</p><ul><li>并查集</li></ul></li></ul></li><li><p><strong>二叉排序树</strong></p><ul><li><p>语言标准库中重要的数据检索容器的底层实现</p><ul><li>AVL树(二叉平衡树)</li><li>2-3树(二叉平衡树)</li><li>红黑树(二叉平衡树)</li></ul></li></ul></li><li><p><strong>文件系统、数据库底层的重要数据结构</strong></p><ul><li>B树/B+树(多叉平衡树)</li></ul></li></ul><h3><strong>3.3 练习递归技巧的最佳选择</strong></h3><p><strong>学习递归的顶层思维方式:</strong></p><p>设计/理解一个递归程序:</p><ol><li><strong>数学归纳法 => 结构归纳法</strong></li></ol><p>若 k0 是正确的,假设 ki 是正确的,那么 k(i+1) 也是正确的。如求解斐波那契数列:</p><pre><code>function fib(n) {
// 首先要确定k0是正确的,也就是前提条件(边界条件)是正确的,在这题中,k0就是n=1时,结果为1,n=2时,结果为2
if(n <= 2) return n;
return fib(n - 1) + fib(n - 2);
}</code></pre><ol start="2"><li><strong>赋予递归函数一个明确的意义</strong></li></ol><p>上面代码中,fib(n)代表第n项斐波那契数列的值。</p><ol start="3"><li><strong>思考边界条件</strong></li></ol><p>在上面的代码中,我们的边界就是已知条件,n=1 时为 1,n=2 时为 2,需要对这个边界进行特殊处理。</p><ol start="4"><li><strong>实现递归过程</strong></li></ol><p>处理完边界问题后,就可以递归继续往下走了。</p><p>如果让你设计一个二叉树的前序遍历的程序,你会怎么设计呢?</p><ol><li><strong>函数意义</strong>:前序遍历以root为根节点的二叉树;</li><li><strong>边界条件</strong>:root为空时无需遍历,直接返回root;</li><li><strong>递归过程</strong>:分别前序遍历左子树和前序遍历右子树。</li></ol><pre><code class="js">// 函数意义:前序遍历以root为根节点的二叉树
function pre_order(root) {
// 边界条件:root为空时无需遍历,直接返回root
if(!root) return root;
console.log(root.val);
// 递归过程:分别前序遍历左子树和前序遍历右子树
pre_order(root.left);
pre_order(root.right);
}</code></pre><h3><strong>3.4 使用左孩子有兄弟法节省空间</strong></h3><p>将任意的非二叉树转换成二叉树,如将一个三叉树转换成二叉树:</p><pre><code># 注意,要始终保证二叉树的左边是子节点,右边是兄弟节点
# 原三叉树
1
/ | \
2 3 4
/ \
5 6
# 按照左孩子右兄弟的方式转换成二叉树
1
/
2
\
3
/ \
5 4
\
6
# 因为2是1的孩子,所以放在左子树,因为3是2的兄弟,所以放在2的右子树,4是3的兄弟,放在3的右子树,5是3的孩子,放在3的左子树,6是5的兄弟,所以放在5的右子树</code></pre><p>大家可以发现,当只是将一棵树通过左孩子右兄弟法转换成二叉树时,根节点的右子树始终为空,那么,我们是不是可以有效地利用这个右子树,把多棵树合并到一棵二叉树中呢?例如下面的示例,就是将两颗二叉树合并到了一起,形成了森林。</p><pre><code># 如果要把下面的两棵树合并到一个二叉树中呢
1 7
/ | \ / \
2 3 4 8 9
/ \
5 6
1
/ \
2 7
\ /
3 8
/ \ \
5 4 9
\
6
# 这样,我们就将两棵树合并成一颗树了,也就是森林了。这棵树看似一颗二叉树,但其实表示的是两棵树组成的森林</code></pre><p>众所周知的Alpha Go的算法源码中实现的<a href="https://link.segmentfault.com/?enc=GiMh88qZZMSsLpM6482plg%3D%3D.NbvyH734%2BdMydGT25hMgkkwYBnPrAVMgKvPHW2toF1vYuXLV%2BHYW8tY%2B6ij3s3OHF%2Bpg1vGxsegz2CWLBaM%2FUzX4kml7m0NgI17A9xVWTblcCSaUWvtO5ZOtH2DkARLs4FLs2yG%2FBUmNjGtdlRMUZOxGfaK41XAMIih8j4OZvVw%3D" rel="nofollow">蒙特卡罗树搜索</a>算法框架的具体实现算法,称之为信心上限树算法(<a href="https://link.segmentfault.com/?enc=9iqpx6rUmcZ7yu9lPQuyiA%3D%3D.OhKlwpsqfUF1h3LdCXX7BCdyDF%2BGO7c%2BpWLOLqUEvHSA6BqU40%2BefP6IfThB6bPkFQ%2BkD8x%2Fbg%2BR%2BY4%2Bd7hr9d3%2Bi%2BXs5A2B4B8n5hhZmXY%3D" rel="nofollow">UCT</a>)就是采用了左孩子右兄弟法实现的一颗搜索树,用来表示整个棋盘的局面,正常来说,如果要存储一个棋盘的局面的话,会存储一个树形的结构中,但因为棋盘局面情况太多了,有可能形成一个 100 多叉以上的树,在Alpha Go 中为了避免这种情况,就把这个100多叉树通过左孩子有兄弟的表示法转换成了二叉树。有兴趣的同学可以去看一下 <a href="https://link.segmentfault.com/?enc=ay9L6crl4di7K%2FBHw33wCQ%3D%3D.3mO9li38EIXwbGPgbsZyEn0YojrjWf6v%2FoQr6%2BBDTm4HO3qNTgOO8WVNh47NCXUM%2B9kgxZHlKV%2FyCag3%2BLT9wg%3D%3D" rel="nofollow">pachi</a>。</p><p>那么,为什么说这种方式能够节省空间呢?大家想想,一个三叉树,他的每个节点都会有三个指针域用于存储他的子树,不管是否有子树,都要预留这些空间,如上面的三叉树,有6个节点,总共有18个指针域,其中有效的指针域只有 5 个(所谓有效指针域就是指针域不是指向空的,即边的数量=节点数量 -1 ),那么就还有 18-5=13 个指针是空着的。如果采用左孩子右兄弟的方式转换成二叉树,我们来看看总共有 12 个指针域,而有效指针域有 5 个,那么就只有 12-5=7 个指针域空着,明显比之前的 13 个节省了大量空间。</p><p>一个拥有n个节点的k叉树,他最多会有 <strong>k * n</strong> 条边,他的边实际上只有 <strong>n-1</strong> 条,那么他浪费了:<strong>k<em>n - (n-1)=(k-1)</em>n+1</strong>条边,这就意味着,当我们分叉越多,我们浪费的空间就会越多,所以,我们要把k叉树转换成二叉树,因为二叉树浪费的边为:n+1,只跟我们实际存储数据的节点有关。</p><h2><strong>四、结语</strong></h2><p>到了这里,我们关于二叉树的一些基础知识就聊的差不多了,为了控制篇幅以及不同基础的小伙伴的接受程度,就不再展开更深的讨论了。本来还要跟大家一起刷一刷关于二叉树的算法题巩固一下二叉树的一些相关知识的,不过这样就会导致这篇文章又臭又长,所以,还是把它拆分成两篇文章吧。</p>
大厂offer?拿来吧你!网易有道笔试编程题特辑
https://segmentfault.com/a/1190000040508611
2021-08-13T10:00:51+08:00
2021-08-13T10:00:51+08:00
有道AI情报局
https://segmentfault.com/u/youdaotec
1
<p><img src="/img/remote/1460000040508613" alt="首图.gif" title="首图.gif"></p><p><strong>联系我们</strong>:<strong>有道技术团队助手</strong>:ydtech01 / <strong>邮箱</strong>:<a href="mailto:ydtech@rd.netease">ydtech@rd.netease</a>.com</p><p>欢迎应届生同学们</p><p>来到2022年校招运动会</p><p>现在迎面向你们走来的</p><p>是<strong>网易有道</strong>代表队!</p><p>(传送门:<a href="https://link.segmentfault.com/?enc=ZDOWDSHl7TtqnxCMF6frBw%3D%3D.rliV5MhsEYAbsiBjeMDQfmFw9M2Vg3cs3A2fT7rFl3U%3D" rel="nofollow">http://hr.youdao.com/</a> )</p><p><img src="/img/bVcT7kV" alt="" title=""></p><p>他们食堂好吃</p><p>他们从不内卷</p><p>今天,他们还带来了</p><p><strong>10道笔试编程题</strong></p><p>据说全做对的同学</p><p>都顺利地拿到了 offer!</p><p>同学们,请开始你们的 <del>bug</del></p><p>啊不</p><p>表演吧!</p><h2><strong>一、热身运动</strong></h2><h3><strong>1.1</strong> <strong>找到重复数字</strong></h3><p>给定一个包含 <strong>n+1</strong> 个整数的数组 nums ,其数字都在 1 到 n 之间(包括 1 和 n),可知至少存在一个重复的整数。假设 nums 只有一个重复的整数 ,找出这个<strong>重复的数</strong>。</p><ul><li><em>难度:一星</em></li><li><em>时间限制:C/C++ 1秒,其他语言2秒</em></li><li><em>空间限制:C/C++ 256MB,其他语言512MB</em></li><li><em>64bit IO Format: %lld</em>**</li></ul><p><strong>样例:</strong></p><ul><li><strong>输入</strong>:[1,3,4,2,2]</li><li><strong>返回</strong>:2</li></ul><pre><code class="js">import java.util.*;
public class Solution {
/**
* 代码中的类名、方法名、参数名已经指定,请勿修改,直接返回方法规定的值即可
*
* 返回重复数字
* @param nums int整型一维数组
* @return int整型
*/
public int duplicate (int[] nums) {
// write code here
int n = nums.length;
int l = 1, r = n - 1, ans = -1;
while (l <= r) {
int mid = (l + r) >> 1;
int cnt = 0;
for (int i = 0; i < n; ++i) {
if (nums[i] <= mid) {
cnt++;
}
}
if (cnt <= mid) {
l = mid + 1;
} else {
r = mid - 1;
ans = mid;
}
}
return ans;
}
}</code></pre><h3><strong>1.2 </strong><strong>三角形面积</strong></h3><p>输入三个点的坐标,输出三个点组成的<strong>三角形的面积</strong>。(结果保留三位小数点并四舍五入)</p><ul><li><em>难度:一星</em></li><li><em>时间限制:C/C++ 1秒,其他语言2秒</em></li><li><em>空间限制:C/C++ 256MB,其他语言512MB</em></li><li><em>Special Judge, 64bit IO Format: %lld</em></li><li><em>知识点:计算几何</em></li></ul><p><strong>样例:</strong></p><ul><li><strong>输入</strong>:12,-70,95,91,-72,35</li><li><strong>输出</strong>:11119.500</li></ul><pre><code class="js">#include <iostream>
#include <cmath>
#include <cstdio>
using namespace std;
int main() {
double x1, y1, x2, y2, x3, y3;
cin >> x1 >> y1 >> x2 >> y2 >> x3 >> y3;
double xa = (x1 - x2);
double ya = (y1 - y2);
double xb = (x3 - x2);
double yb = (y3 - y2);
float area = fabs((xa * yb - ya * xb) / 2.0);
printf("%.3f", area);
return 0;
}</code></pre><h2><strong>二、伸展运动</strong></h2><h3><strong>2.1 分解自然数</strong></h3><p>一个自然数可以将它分解成若干个自然数相乘。现在给你一个指定的自然数 n,请求出每种<strong>分解自然数之和的最小值</strong>是多少。</p><ul><li><em>难度:二星</em></li><li><em>时间限制:C/C++ 5秒,其他语言10秒</em></li><li><em>空间限制:C/C++ 32MB,其他语言64M</em></li><li><em>64bit IO Format: %lld</em></li></ul><p><strong>样例:</strong></p><ul><li><strong>输入</strong>:6</li><li><strong>返回</strong>:5</li><li><strong>说明</strong>:6分解为2 * 3,那么最小的和为2+3=5</li></ul><pre><code class="js">#
# 代码中的类名、方法名、参数名已经指定,请勿修改,直接返回方法规定的值即可
#
# 得到分解自然数之和的最小值
# @param n int整型 自然数n
# @return int整型
#
class Solution:
def getMinSum(self , n ):
if n <= 1:
return n
temp = int(n / 2)
while temp != 0:
if n % temp == 0:
if temp == 1 and n / temp == n:
print(n)
return n
else:
return self.getMinSum(n / temp) + self.getMinSum(temp)
else:
temp -= 1</code></pre><h3><strong>2.2 恢复异常数</strong></h3><p>有一个一维整数数组 <strong>fuzzyArray</strong>,里面存储的是从 1 到 n 这 n 个数,不过是乱序存储;这时有一个位置的数字变成了 -1。请用<strong>最优的空间复杂度</strong>和<strong>时间复杂度</strong>求出这个异常数的位置和原来的值。</p><ul><li><em>难度:二星</em></li><li><em>时间限制:C/C++ 5秒,其他语言10秒</em></li><li><em>空间限制:C/C++ 256 MB,其他语言512 MB</em></li><li><em>64bit IO Format: %lld</em></li><li><em>知识点:测试开发、数组</em></li></ul><p><strong>样例:</strong></p><ul><li><strong>输入</strong> <strong>:</strong> [2, -1, 3]</li><li><strong>返回:</strong> [1,1]</li><li><strong>说明:</strong> 异常数组原本应该是存储从 1 到 3 的数,不过是乱序的,但是实际数组是 [2, -1, 3],说明数组 pos=1 的位置,原来的数字 1 变成了 -1,因此返回 [1, 1]</li></ul><pre><code class="js">#
# 代码中的类名、方法名、参数名已经指定,请勿修改,直接返回方法规定的值即可
#
# 函数:求出异常数的位置和原来的值
# @param fuzzyArray int整型一维数组 含有异常数的数组
# @return int整型一维数组
#
class Solution:
def fuzzyNumber(self , fuzzyArray ):
flag = 1
pos = 0
sumnumber = 0
index = 0
for item in fuzzyArray:
if item == -1:
if flag == 0:
return [-1, -1]
flag = 0
pos = index
else:
sumnumber += item
index += 1
orisum = (index + 1) * index / 2
orinumber = orisum - sumnumber
return [pos, orinumber]</code></pre><h3><strong>2.3 订单平均等待时间</strong></h3><p>有一个奶茶店,同一时间只能处理一个订单的制作,现有一个顾客订单列表 orders(二维数组),每个订单都包含两个元素:<strong>第一个元素</strong>表示<strong>订单到达的时间</strong>,orders 中订单按到达时间非递减顺序排列;<strong>第二个元素</strong>表示<strong>订单制作需要的时间</strong>;当顾客订单到达时,奶茶店一旦空闲就会开始制作该订单的奶茶。每一位顾客都会一直等待奶茶店完成他的订单。奶茶店会严格按照订单顺序处理订单。请你返回订单列表中所有顾客平均需要等待的时间。与标准答案<strong>误差在 10-5 范围以内</strong>,都视为<strong>正确</strong>。</p><ul><li><em>难度:二星</em></li><li><em>时间限制:C/C++ 1秒,其他语言2秒</em></li><li><em>空间限制:C/C++ 256MB,其他语言512MB</em></li><li><em>Special Judge, 64bit IO Format: %lld</em></li><li><em>知识点:模拟</em></li></ul><p><strong>样例:</strong></p><ul><li><strong>输入</strong>:[[1,2],[1,3],[4,3]]</li><li><strong>返回:</strong> 4.00000</li><li><p><strong>说明:</strong> 第一个订单在时刻1到达,奶茶店立即开始处理订单,在时刻3完成,第一位顾客需要等待的时间为 3-1=2;</p><p>第二个订单在时刻1到达,奶茶店正在处理第一个订单,第一个订单在时刻3完成并开始处理订单2,第二个订单在时刻6完成,第二位顾客需要等待的时间为 6-1=5;</p><p>第三个订单在时刻4到达,奶茶店正在处理第二个订单,第二个订单在时刻6完成并开始处理订单3,第三个订单在时刻9完成,第二位顾客需要等待的时间为 9-4=5;所以<strong>平均值为 (2+5+5)/3=4</strong>。</p></li></ul><pre><code class="js">import java.util.*;
public class Solution {
/**
* 代码中的类名、方法名、参数名已经指定,请勿修改,直接返回方法规定的值即可
*
*
* @param orders int整型二维数组
* @return double浮点型
*/
public double averageWaitingTime (int[][] orders) {
int currentTime=0;
long timeSum=0;//注意越界
for(int[] a:orders){
if(a[0]>currentTime){
timeSum+=a[1];
currentTime=a[0]+a[1];
}else{
timeSum+=a[1]+currentTime-a[0];
currentTime=a[1]+currentTime;
}
}
return (double)timeSum/orders.length;
}
}</code></pre><h2><strong>三、全身运动</strong></h2><h3><strong>3.1 数字与字母</strong></h3><p>给你一个仅包含<strong>数字</strong>和<strong>大写字母</strong>的字符数组,找到一个最长的子串,使得子串中包含<strong>相同个数</strong>的数字和字母。子串必须是原数组中<strong>连续</strong>的一部分。请你返回子串的长度 ,若没有这样的子串返回 0 。</p><ul><li><em>难度:三星</em></li><li><em>时间限制:C/C++ 1 秒,其他语言 2 秒</em></li><li><em>空间限制:C/C++ 256 MB,其他语言512 MB</em></li><li><em>64bit IO Format: %lld</em></li><li><em>知识点:字符串处理</em></li></ul><p><strong>样例:</strong></p><ul><li><strong>输入:</strong> [A,A,A]</li><li><strong>返回:</strong> 0</li></ul><pre><code class="js">import java.util.*;
public class Solution {
/**
* 代码中的类名、方法名、参数名已经指定,请勿修改,直接返回方法规定的值即可
*
*
* @param str char字符型一维数组
* @return int整型
*/
public int findLongestSubarray (char[] str) {
Map<Integer,Integer> map = new HashMap<>();
map.put(0,-1);
int prefixSum = 0;
int longest = 0;
for (int i = 0; i < str.length; i++) {
char c = str[i];
prefixSum += Character.isDigit(c)?-1:1;
if (!map.containsKey(prefixSum)){
map.put(prefixSum,i);
}else{
// i-map.get(prefixSum) == i-left+1
if (i-map.get(prefixSum)>longest){
longest = i-map.get(prefixSum);
}
}
}
return longest;
}
}</code></pre><h3><strong>3.2 木棍拼接</strong></h3><p>木工小王有一些长短不一的木棍,他想知道这些木棍能否拼接起来组成一个正方形。请写一个程序解决小王的疑惑。</p><p><strong>说明</strong>:</p><ol><li>可将单根木棍作为正方形的一条边,也可将多根木棍拼接起来作为正方形的一条边。</li><li>所有木棍必须使用,且每根木棍<strong>只能使用一次</strong>。</li></ol><ul><li><em>难度:三星</em></li><li><em>时间限制:C/C++ 1秒,其他语言2秒</em></li><li><em>空间限制:C/C++ 32MB,其他语言64MB</em></li><li><em>64bit IO Format: %lld</em></li><li><em>知识点:dfs、剪枝</em></li></ul><p><strong>样例:</strong></p><ul><li><strong>输入:</strong> [4,1,1,1]</li><li><strong>返回:</strong> [false]</li><li><strong>说明:</strong> 这四根木棍无法拼接成正方形</li></ul><pre><code class="js">#include <algorithm>
#include <numeric>
class Solution {
public:
/**
* 代码中的类名、方法名、参数名已经指定,请勿修改,直接返回方法规定的值即可
*
* 判断输入不同长度木棍能否拼接成一个正方形
* @param sticks int整型vector 输入木棍长度
* @return bool布尔型
*/
bool canLinkToSquare(vector<int>& sticks) {
if (sticks.size() < 4) {
return false;
}
int len = std::accumulate(sticks.begin(), sticks.end(), 0);
if (len == 0 || len % 4 != 0) {
return false;
}
int max = *std::max_element(sticks.begin(), sticks.end());
if (max > len / 4) {
return false;
}
std::sort(sticks.begin(), sticks.end());
std::vector<bool> marks(sticks.size(), false);
return dfs(sticks, marks, len / 4, 0, 0, 0);
}
/**
*
* 利用dfs判断输入不同长度木棍能否拼接成一个正方形
* @param sticks int整型vector 输入木棍长度
* @param marks bool vector 木棍是否被使用
* @param len int整型 木棍边长
* @param count int整型 已拼成的边的个数
* @param l int整型 当前边的长度
* @param pos size_t整型 当前使用的木棍位置
* @return bool布尔型
*/
bool dfs(const vector<int> &sticks, vector<bool> &marks, const int len,
int count, int l, size_t pos) {
if (count == 3) return true;
for (int i = pos; i < sticks.size(); i++) {
if (marks[i]) continue;
if (l + sticks[i] == len) {
marks[i] = true;
if (dfs(sticks, marks, len, count + 1, 0, 0))
return true;
marks[i] = false;
return false;
} else if (l + sticks[i] < len) {
marks[i] = true;
if (dfs(sticks, marks, len, count, l + sticks[i], i + 1))
return true;
marks[i] = false;
if (l == 0)
return false;
while (i + 1 < sticks.size() && sticks[i] == sticks[i + 1])
i++;
}
}
return false;
}
};</code></pre><h3><strong>3.3</strong> <strong>删除最短子数组使剩余数组有序</strong></h3><p>输入一个整数数组 array,请你删除一个子数组,使得 array 中剩下的元素是<strong>非递增</strong>的。子数组可以是原数组中连续的一个子序列,或者为空。请你返回这个<strong>最短的子数组的长度</strong>。</p><ul><li><em>难度:三星</em></li><li><em>时间限制:C/C++ 1秒,其他语言2秒</em></li><li><em>空间限制:C/C++ 256MB,其他语言512MB</em></li><li><em>64bit IO Format: %lld</em></li><li><em>知识点:数组</em></li></ul><p><strong>样例:</strong></p><ul><li><strong>输入:</strong> [5,4,3,7,8,2,1]</li><li><strong>返回值:</strong> 2</li><li><strong>说明:</strong> 删除的最短子数组是 [7,8],长度是 2。剩余的元素为 [5,4,3,2,1],为非递增。</li></ul><pre><code class="js">import java.util.*;
public class Solution {
/**
* 代码中的类名、方法名、参数名已经指定,请勿修改,直接返回方法规定的值即可
*
*
* @param array int整型一维数组 原数组
* @return int整型
*/
public int findLengthOfShortestSubarray (int[] arr) {
int n = arr.length;
int left = 0;
while (left + 1 < n && arr[left] >= arr[left+1]) {
left++;
}
// [0...left]有序
if (left == n - 1) {
return 0;
}
// [right...n-1]有序
int right = n - 1;
while (right > 0 && arr[right - 1] >= arr[right]) {
right--;
}
// 完全删除一边[left+1, n-1], 或者[0...right - 1]
int result = Math.min(n - left - 1, right);
// 左边和右边各保留一部分
int i = 0, j = right;
while (i <= left && j <= n - 1) {
if (arr[i] >= arr[j]) {
// [0...i] 和 [j...n-1] 有序, 删除 [i+1...j-1]
result = Math.min(result, j - i - 1);
i++;
} else {
// 小的+1
j++;
}
}
return result;
}
}</code></pre><h2><strong>四、跳跃运动</strong></h2><h3><strong>4.1 任务分配</strong></h3><p>在离线机器翻译系统中有时会一次接受到多个翻译句子的请求,这些句子的翻译时间可以按照长度预估为 <strong>jobs</strong>,jobs[i]表示第i个请求句子的翻译时间。系统会启动 k 个线程同时去处理这些翻译任务。为了减少响应时间,我们需要将这些翻译请求分配给不同的线程去处理,每个请求只能分配给一个线程,一个线程的处理时间为分配给它的所有请求句子翻译时间的和。系统的处理时间为所有线程翻译完分配任务的时间,你的目标是<strong>优化分配方式</strong>使得系统能尽快时间处理完所有请求。请计算出整个系统<strong>最短的处理时间。</strong></p><ul><li><em>难度:五星</em></li><li><em>时间限制:C/C++ 1秒,其他语言2秒</em></li><li><em>空间限制:C/C++ 32MB,其他语言64MB</em></li><li><em>64bit IO Format: %lld</em></li><li><em>知识点:贪心、线性动态规划</em></li></ul><p><strong>样例:</strong></p><ul><li><strong>输入:</strong> [3,2,3],3</li><li><strong>返回:</strong> 3</li><li><strong>说明:</strong> 三个请求分配给三个任务,系统处理时间为3</li></ul><pre><code class="js">class Solution {
public:
/**
* 代码中的类名、方法名、参数名已经指定,请勿修改,直接返回方法规定的值即可
*
* 调度jobs中的任务,分配给k个worker处理,返回系统最短的处理时间
* @param jobs int整型vector 翻译时长数组
* @param k int整型 开启翻译线程数
* @return int整型
*/
int minimumProcessTime(vector<int>& jobs, int k) {
// write code here
int n = jobs.size();
if (n <= k) {
return *max_element(jobs.begin(), jobs.end());
}
vector<int> tot(1 << n, 0);
for (int i = 1; i < (1 << n); i++) {
for (int j = 0; j < n; j++) {
if ((i & (1 << j)) == 0) continue;
int left = (i - (1 << j));
tot[i] = tot[left] + jobs[j];
break;
}
}
vector<vector<int>> dp(k, vector<int>(1 << n, -1));
for (int i = 0; i < (1 << n); i++) {
dp[0][i] = tot[i];
}
for (int j = 1; j < k; j++) {
for (int i = 0; i < (1 << n); i++) {
int minv = 1e9;
for (int s = i; s; s = (s - 1) & i) { // 枚举 i 的全部子集
int left = i - s;
int val = max(dp[j-1][left], tot[s]);
minv = min(minv, val);
}
dp[j][i] = minv;
}
}
return dp[k-1][(1<<n)-1];
}
};</code></pre><h3><strong>4.2 熟能生巧</strong></h3><p>卖油翁有两个油壶,它们的容量分别为 a 升和 b 升,顾客想要购买 c 升的油,由于两个油壶<strong>都没有刻度</strong>,因此卖油翁只能采取如下3种操作:</p><ol><li>将其中一个油壶装满油</li><li>将其中一个油壶的油全部倒掉</li><li>将一个油壶的油倒入另一个油壶中。如果源油壶油的容量大于目标油壶剩余容积,则经过此操作后源油壶保留剩余容量,目标油壶装满油,否则经过此操作后源油壶容量为空,目标油壶容量为之前容量+源油壶容量。</li></ol><p>卖油翁想知道能否经过若干次上述操作后使得其中一个油壶中油的容量等于顾客的购买容量c升。请写一个程序来解决卖油翁的问题,如果可经过数次操作得到目标容量则输出需要操作的<strong>最少次数</strong>,否则输出 <strong>-1</strong>。</p><ul><li><em>难度:五星</em></li><li><em>时间限制:C/C++ 1秒,其他语言2秒</em></li><li><em>空间限制:C/C++ 32MB,其他语言64MB</em></li><li><em>64bit IO Format: %lld</em></li><li><em>知识点:bfs</em></li></ul><p><strong>样例:</strong></p><ul><li><strong>输入:</strong> [5,3,6]</li><li><strong>返回:</strong> [-1]</li><li><strong>说明:</strong> [不能经过数次操作使得其中一个油壶中油的容量等于6]</li></ul><pre><code class="js">class Solution {
public:
// 两油壶状态
class State {
public:
State(int _a, int _b) : a(_a), b(_b), step(0), op(-1) {};
public:
int a; // a壶油量
int b; // a壶油量
int step; // 经过多少步到达此状态
int op; // 到达此状态经过的操作编号
};
void update(std::queue<State> &q, std::vector<std::vector<bool>> &visited,
State &next, int step, int op) {
assert(next.a >= 0 && next.b >= 0);
if (!visited[next.a][next.b]) {
next.step = step;
next.op = op;
q.push(next);
visited[next.a][next.b] = true;
}
}
/**
* 代码中的类名、方法名、参数名已经指定,请勿修改,直接返回方法规定的值即可
*
* 能否经过数次操作得到目标容量,可以的话请输出需要操作的最小次数,不可以的话请输出-1。
* @param a int整型 a壶容量
* @param b int整型 b壶容量
* @param c int整型 目标容量
* @return int整型
*/
int findShortestStep(int a, int b, int c) {
if (c > a && c > b)
return -1;
if (c == a || c == b)
return 1;
if (a == b)
return -1;
else if (b > a)
std::swap(a, b);
if (c > b && c < a && a % b == 0 && c % b == 0) {
int ua = a / b;
int uc = c / b;
return std::min(ua - uc, uc) * 2;
}
if (c == a - b) {
return 2;
}
State init(0, 0);
std::vector<std::vector<bool>> visited(a + 1, std::vector<bool>(b + 1, false));
visited[0][0] = true;
std::queue<State> q;
q.push(init);
while (!q.empty()) {
State s = q.front();
if (s.a == c || s.b == c) {
return s.step;
}
// fill a
State next(0, 0);
if (s.a < a) {
next.a = a;
next.b = s.b;
update(q, visited, next, s.step + 1, 0);
}
// fill b
if (s.b < b) {
next.a = s.a;
next.b = b;
update(q, visited, next, s.step + 1, 1);
}
// drop a
if (s.a) {
next.a = 0;
next.b = s.b;
update(q, visited, next, s.step + 1, 2);
}
// drop b
if (s.b) {
next.a = s.a;
next.b = 0;
update(q, visited, next, s.step + 1, 3);
}
// pour a to b
if (s.a && s.b < b) {
if (s.a <= b - s.b) {
next.a = 0;
next.b = s.b + s.a;
} else {
next.a = s.a - (b - s.b);
next.b = b;
}
update(q, visited, next, s.step + 1, 4);
}
// pour b to a
if (s.b && a > s.a) {
if (s.b <= a - s.a) {
next.a = s.a + s.b;
next.b = 0;
} else {
next.b = s.b - (a - s.a);
next.a = a;
}
update(q, visited, next, s.step + 1, 5);
}
q.pop();
}
return -1;
}
};</code></pre><p>无论你是<strong>功力深厚的代码大神</strong></p><p>还是<strong>努力成长的勇敢牛牛</strong></p><p><strong>有道技术团队</strong>都期待你的加入!</p><p>欢迎投递<strong>网易有道</strong>!<br>(传送门:<a href="https://link.segmentfault.com/?enc=Hc6fqOk%2Fn4QIbwzBZvg2RA%3D%3D.BlzVSjG%2BTtbGhUfllRI1rAfoVzhFMyn0N8HKNZXfCcE%3D" rel="nofollow">http://hr.youdao.com/</a> )</p><p><strong>彩蛋:</strong> <em>8月16日(周一)19:00,网易有道 2022 校招技术空宣专场与你面对面,答疑解惑、揭晓有道工作一手秘闻!</em></p>
中国大学 MOOC Android 性能优化:冷启动优化总结
https://segmentfault.com/a/1190000040458955
2021-08-05T10:08:10+08:00
2021-08-05T10:08:10+08:00
有道AI情报局
https://segmentfault.com/u/youdaotec
2
<p><img src="/img/remote/1460000040458957" alt="首图.gif" title="首图.gif"></p><p><strong>联系我们:</strong><br><strong>有道技术团队助手</strong>:ydtech01 / <strong>邮箱</strong>:<a href="mailto:ydtech@rd.netease">ydtech@rd.netease</a>.com</p><p>本文的重点在于如何定量的排查冷启动过程中的耗时操作,并提供对应的优化思路和实践方法总结。同时本文涉及到的冷启动优化主要涵盖两个方面:Application 的性能优化和 Launcher Activity 的性能优化。</p><h2><strong>一、背景</strong></h2><p>中国大学 MOOC 是网易与高教社携手推出的在线教育平台,目前,经过长期的产品打磨和钻研,在课程数量、质量以及影响力,中国大学 MOOC 已成为全球领先的中文慕课平台。同时经过此次优化,冷启动速度<strong>整体提升27%。</strong></p><p>在我们日常开发中,随着 app 整体迭代次数增多,由于长久以来的迭代需求,android app 本身也集成了较多的第三方组件和 SDK,同时在日常迭代中,也是以业务迭代需求实现为主要目的,导致现在 app 本身,或多或少存在一些性能可优化空间。所以有必要进行性能优化,提升用户体验</p><p>此次优化,主要侧重于两个方面:</p><ul><li>Application 的性能优化</li><li>app 启动页性能优化</li></ul><p><strong>该文档重点不在于代码规范和业务代码逻辑导致的性能问题,而是在假设代码无明显、严重性能漏洞,并且不改变原有业务逻辑,量化性能监测数据和问题,并针对其进行优化修改。</strong></p><h2><strong>二、冷启动速度优化</strong></h2><h3><strong>2.1 相关知识点</strong></h3><h4><strong>2.1.1 冷启动耗时统计</strong></h4><pre><code class="js">adb shell am start -S -W [packageName]/[activiytName]</code></pre><p>上述 adb 命令中,几个关键参数说明:</p><ul><li><strong>-S</strong>:表示启动该 app 前先彻底关闭当前 app 进程</li><li><strong>-W</strong>:启动并输出相关耗时数据</li><li><strong>packageName</strong>:app 的 applicationID</li><li><strong>activityName</strong>:app 启动需要拉起的 Activity,如果用于统计冷启动耗时,那么该参数即为应用的第一个启动的 Activity(intent-filter 为 LAUNCHER 的 Activity)</li></ul><p>再执行上诉 adb 后,会成功唤起 APP,并在控制台输出三个比较关键的参数:</p><p><img src="/img/bVcTVsr" alt="" title=""></p><ul><li><strong>LaunchState</strong>:启动模式,上诉启动模式为冷启动</li><li><strong>WaitTime</strong>:系统启动应用耗时= TotalTime +系统资源启动时间(单位 ms )</li><li><strong>TotalTime</strong>:应用自身启动耗时=该 Activity 启动时间+应用 application 等资源启动时间(单位 ms )</li></ul><p><strong>对于应用层面得冷启动性能优化,我们关注的时间 TotalTime,该时间大致可以概括为:Application 构造方法→该 Activity 的 onWindowFocusChange 方法时间总和。而这个过程也可以粗略认知为,用户点击桌面图标到 app 第一个 Activity 获取焦点,业务代码执行的总时间(针对业务代码的优化,我们暂时不关心 Zygote 进程、Launcher 进程、AMS 进程的交互)。</strong></p><h4><strong>2.1.2 冷启动耗时堆栈观察方法:</strong></h4><p>在 Android API>=26 的系统版本中,建议使用 CPU Profile 或者 Debug.startMethodTracing 进行监控并导出 trace 文件进行分析。不管哪种方式,采集堆栈信息都有两种模式:采样模式和追踪模式。追踪模式会一直抓取数据,对设备性能要求较高。</p><p><strong>(1)CPU Profile</strong></p><p><img src="/img/bVcTVss" alt="" title=""></p><p><strong>(2)Debug.startMethodTracing</strong></p><p>由于冷启动涉及到业务应用层面的时间是:该 Activity 启动时间+应用 application 等资源启动时间,所以我们在 Application 构造方法中开始采集,在第一个 Activity 的 onWindowFocusChange 中停止采集,并输出 trace文件。</p><pre><code class="js">/**
* 在Application构造方法中开始采集
*/
public UcmoocApplication() {
//保存Trace文件的目录
File file = new File(Environment.getExternalStorageDirectory(), "ucmooc.trace");
//采集方式有以下两种,根据需求选择其一
//第一种:通过采样的方式,追踪堆栈信息
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
//通过采样方式追踪堆栈信息,需要指定文件保存目录、文件最大大小(单位M)、采样间隔(单位us)
Debug.startMethodTracingSampling(file.getAbsolutePath(), 8, 1000);
}
//第二种:通过追踪的方式,全量采集堆栈信息
Debug.startMethodTracing(file.getAbsolutePath());
coreApplication = new CoreApplication();
}</code></pre><pre><code class="js">/**
* 在启动后的第一个Activity的onWindowFocusChanged中停止监听
*
* @param hasFocus
*/
@Override
public void onWindowFocusChanged(boolean hasFocus) {
super.onWindowFocusChanged(hasFocus);
Debug.stopMethodTracing();
}</code></pre><p><strong>同时,由于该操作涉及到文件读写权限,需要手动授予 APP 该权限</strong></p><h4><strong>2.1.3 .trace 日志文件阅读:</strong></h4><p>在导出获取到 .trace 文件后,把 .trace 拖动至 androidStudio 编辑区;或者直接浏览 CPU Profile 视图,便可对程序运行的堆栈进行分析:</p><p><img src="/img/bVcTVsu" alt="" title=""></p><p>上图就是 trace 文件打开后的效果,展示的是基于 CPU 使用和线程运行状况,针对启动速度的优化,需要关注的上图标注的几个点:</p><p>(1)CPU 运行时间轴:横向拖动可以选择查看的时间范围</p><p>(4)当前设备 CPU 轮转的线程:点击可以选择需要查看的线程,我们重点关注主线程</p><p>(2)当前选择线程,跟随时间轴,各个方法栈的调用情况和其耗时状况。其不同颜色分别代表</p><blockquote><ul><li><strong>黄色</strong>:android 系统方法(FrameWork 层代码,如果需要最终更底层的方法,需要最终 C/C++ 方法调用栈)</li><li><strong>蓝色</strong>:Java JDK 方法</li><li><strong>绿色</strong>:属于当前 app 进程执行的方法,包括一些类加载器和我们的业务代码(启动速度优化主要针对这一部分)</li></ul></blockquote><p>(3)各个方法栈的调用顺序和耗时情况,可以选择不用的排序方式和视图。</p><p><strong>所以一般排查耗时方法时,建议先通过(2)视图直观检测到耗时较为严重的方法,锁定后,在(3)视图中查看具体的方法调用顺序。</strong></p><h3><strong>2.2 优化步骤</strong></h3><p>由于在冷启动过程中,业务代码耗时主要集中在 Application 和 launcher Activity 中,所以优化过程也是分别针对这两块进行优化。</p><h4><strong>2.2.1 优化成果</strong></h4><p>使用2.1.1的方式,在优化前后,分别做了10次冷启动耗时统计,结果如下:</p><p><img src="/img/bVcTVsv" alt="" title=""></p><p>启动速度<strong>整体提升 27%。</strong></p><h4><strong>2.2.2 Application 优化</strong></h4><p><img src="/img/bVcTVsx" alt="" title=""></p><p>通过 trace 文件,可以直观的发现,在 application 中,耗时最长的方法是其生命周期中的 onCreate 方法,其中在 onCreate 方法中,耗时比较长的方法有:<strong>initMudleFactory、initURS、Unicorn.init、initUmeng。</strong></p><p><img src="/img/bVcTNAB" alt="" title=""></p><p>在 Top Down 视图中,可以更加直观的看出,此次采样,也正是这四个方法耗时最多。</p><p>通过源码排查,这是个方法,均是第三方 SDK 的初始化,同时在这几个 SDK 内部,都含有较多的 IO 操作,并且内部实现了线程管理以保证线程安全,所以可以将这几个 SDK 的初始化,放在子线程中完成。这里以友盟 SDK 为例:</p><pre><code class="js">/**
* 友盟SDK中有涉及到线程不安全的地方,都自己维护了线程,保证线程安全
**/
try {
var6 = getClass("com.umeng.umzid.ZIDManager");
if (var6 == null) {
Log.e("UMConfigure", "--->>> SDK 初始化失败,请检查是否集成umeng-asms-1.2.x.aar库。<<<--- ");
(new Thread() {
public void run() {
try {
Looper.prepare();
Toast.makeText(var5, "SDK 初始化失败,请检查是否集成umeng-asms-1.2.X.aar库。", 1).show();
Looper.loop();
} catch (Throwable var2) {
}
}
}).start();
return;
}
} catch (Throwable var27) {
}
/**
* 在友盟SDK内部中有很多IO操作的地方,和加锁操作,所以可以将SDK初始化操作,放在子线程中
**/
if (!TextUtils.isEmpty(var1)) {
sAppkey = var1;
sChannel = var2;
UMGlobalContext.getInstance(var3);
k.a(var3);
if (!needSendZcfgEnv(var3)) {
FieldManager var4 = FieldManager.a();
var4.a(var3);
}
synchronized(PreInitLock) {
preInitComplete = true;
}
}</code></pre><p>最终,我们可以把上面提到的几个 SDK 初始化工作放入在子线程中:</p><pre><code class="js">private void initSDKAsyn(){
new Thread(() -> {
if (Util.inMainProcess()){
// 登录
initURS();
if (BuildConfig.ENTERPRISE) {
Unicorn.init(BaseApplication.this, "", QiyuOptionConfig.options(), new QiyuImageLoader());
initModuleRegister();
} else {
Unicorn.init(BaseApplication.this, "", QiyuOptionConfig.options(), new QiyuImageLoader());
}
// 初始化下载服务
try {
initDownload();
} catch (Exception e) {
NTLog.f(TAG, e.toString());
}
}
initModuleFactory();
initUmeng();
}).start();
}</code></pre><p><strong>对于一些必须在主线程中初始化完成的 SDK,可以考虑使用 IdleHandler,在主线程空闲时,完成初始化(关于 IdleHandler 会在下面讲到)。</strong></p><h4><strong>2.2.3 Launcher Activity 优化</strong></h4><p>auncher Activity 是 WelcomeActivity,在对 Application 优化结束后,再对 WelcomeActivity 进行优化,还是和上路的思路一样,先通过 trace 文件追踪:</p><p><img src="/img/bVcTVsA" alt="" title=""></p><p>可以看到,在 WelcomeActivity 的 onCreate 方法中,耗时较多的三个地方,分别是:initActionBar、EventBus.register、setContentView,下面针对这三块内容,分别进行对应的优化操作:</p><p><img src="/img/bVcTVsC" alt="" title=""></p><blockquote><p><strong>(1)initActionBar</strong></p><p>在上图中,可以看到,initActionBar 中最耗时的操作是 getSupportActionBar,通过研究代码发现,在 WelcomeActivity 中,并不需要操作 actionBar,所以直接复写父类方法,去掉 super 调用即可。</p><p><strong> (2)EventBus.register</strong></p><p>EventBus 注册时,性能较差,是因为在改过程中涉及到大量的反射操作,所以对性能损耗较大。通过查看官方文档,该问题在 EventBus3.0 中得到了很好的处理,主要是通过 apt 技术增加索引,提升效率。(当前项目未升级版本,待后期优化)</p><p><strong>(3)setContentView</strong></p><p>setContentView 是 Activity 渲染布局时的必要方法,其耗时的点在于,解析 xml 布局文件时,使用了反射,所以如果 xml 布局文件非常复查的时候,可以使用androidx.asynclayoutinflater:asynclayoutinflater进行异步加载 xml 文件,使用方式如下:</p><pre><code class="js">new AsyncLayoutInflater(this).inflate(R.layout.activity_welcome, null,
(view, resid, parent) -> {
setContentView(view);
});</code></pre></blockquote><h2><strong>三、优化方法总结</strong></h2><p>上面针对冷启动优化是基于当前项目本身做的步骤,这里汇总一些冷启动通用的<strong>优化思路</strong>:</p><p><strong>(1)合理的使用异步初始化、延迟初始化和懒加载机制</strong>:主要针对 Application 中各种 SDK 的初始化</p><p><strong>(2)在主线程中应当避免很耗时的操作</strong>,比如 IO 操作、数据库读写操作</p><p><strong>(3)简化 launcher Activity 的布局结构</strong>,如果非常复杂的布局,可以有以下两种方式进行优化:</p><ul><li>建议使用约束布局(ConstraintLayout)来减少布局嵌套避免过度渲染。</li><li>使用 androidx.asynclayoutinflater:asynclayoutinflater 进行异步加载 xml 文件。</li></ul><p><strong>(4)合理使用 IdleHandler 进行延迟初始化</strong>,使用方式如下:</p><pre><code class="js">/**
* 需要在当前线程中处理耗时任务,并且并不需要马上执行的话,可以使用IdleHandler
* 这样该任务可以消息队列空闲时,被处理
*/
Looper.myQueue().addIdleHandler(new MessageQueue.IdleHandler() {
@Override
public boolean queueIdle() {
//此处添加处理任务
//返回值为false:则执行完毕之后移除这条消息,
//返回值为true:则则执行完毕之后依然保留,等到下次空闲时会再次执行,
return false;
}
});</code></pre><p><strong>(5)开始严苛模式(StrictMode)</strong></p><p>该模式并不能帮我们自动优化性能,而是可以帮助我们检测出我们可能无意中或者一些第三方 SDK 中做的会阻塞 Main 线程的事情(比如磁盘操作、网络操作),并将它们提醒出来,以便在开发阶段进行修复。其检测策略有线程检测策略和虚拟机检测策略,我们可以设置需要检测的操作,当代码操作违规时,可以通过 Logcat 或者直接崩溃的形式提醒我们,<strong>具体使用方式如下</strong>:</p><pre><code class="js">/**
* 开启严苛模式,当代码有违规操作时,可以通过Logcat或崩溃的方式提醒我们
*/
private void startStrictMode() {
if (BuildConfig.DEBUG) { //一定要在Debug模式下使用,避免在生产环境中发生不必要的崩溃和日志输出
//线程检测策略
StrictMode.setThreadPolicy(new StrictMode.ThreadPolicy.Builder()
.detectDiskReads() //检测主线程磁盘读取操作
.detectDiskWrites() //检测主线程磁盘写入操作
.detectNetwork() //检测主线程网络请求操作
.penaltyLog() //违规操作以log形式输出
.build());
//虚拟机检测策略
StrictMode.setVmPolicy(new StrictMode.VmPolicy.Builder()
.detectLeakedSqlLiteObjects() //检测SqlLite泄漏
.detectLeakedClosableObjects() //检测未关闭的closable对象泄漏
.penaltyDeath() //发生违规操作时,直接崩溃
.build());
}
}</code></pre>
JAVA语言异步非阻塞设计模式(应用篇)
https://segmentfault.com/a/1190000040385161
2021-07-22T10:03:29+08:00
2021-07-22T10:03:29+08:00
有道AI情报局
https://segmentfault.com/u/youdaotec
1
<p><img src="/img/remote/1460000040385163" alt="首图.gif" title="首图.gif"></p><h2>1.概述</h2><p>本系列文章共2篇。在上一篇《原理篇》中,我们看到了异步非阻塞模型,它能够有效降低线程IO状态的耗时,提升资源利用率和系统吞吐量。异步 API 可以表现为 listener 或 Promise 形式;其中 Promise API 提供了更强的灵活性,支持同步返回和异步回调,也允许注册任意数目的回调。</p><p>在本文《应用篇》中,我们将进一步探索异步模式和 Promise 的应用:</p><p><strong>第2章:Promise与线程池。</strong> 在异步执行耗时请求时,ExecutorService+Future是一个备选方案;但是相比于Future,Promise支持纯异步获取响应数据,能够消除更多阻塞。</p><p><strong>第3章:异常处理。</strong> Java 程序并不总能成功执行请求,有时会遇到网络问题等不可抗力。对于无法避免的异常情况,异步 API 必须提供异常处理机制,以提升程序的容错性。</p><p><strong>第4章:请求调度。</strong> Java 程序有时需要提交多条请求,这些请求之间可能存在一定的关联关系,包括顺序执行、并行执行、批量执行。异步 API 需要对这些约束提供支持。</p><p>本文不限定 Promise 的具体实现,读者在生产环境可以选择一个Promise工具类(如netty DefaultPromise[A]、jdk CompletableFuture[B]等);此外,由于Promise的原理并不复杂,读者也可以自行实现所需功能。</p><h2>2.Promise 与线程池</h2><p>Java 程序有时需要执行耗时的 IO 操作,如数据库访问;在此期间,相比于纯内存计算,IO操作的持续时间明显更长。为了减少 IO 阻塞、提高资源利用率,我们应该使用异步模型,将请求提交到其他线程中执行,从而连续提交多条请求,而不必等待之前的请求返回。</p><p>本章对几种 IO 模型进行对比(见2.1节),考察调用者线程的阻塞情况。其中,Promise 支持纯异步的请求提交及响应数据处理,能够最大程度地消除不必要的阻塞。在实际项目中,如果底层 API 不支持纯异步,那么我们也可以进行适当重构,使其和 Promise 兼容(见2.2节)。</p><h3>2.1 对比:同步、Future、Promise</h3><p>本节对几种 IO 模型进行对比,包括同步 IO、基于线程池(ExecutorService)的异步 IO、基于 Promise 的异步 IO,考察调用者线程的阻塞情况。假设我们要执行数据库访问请求。由于需要跨越网络,单条请求需要进行耗时的 IO 操作,才能最终收到响应数据;但是请求之间没有约束,允许随时提交新的请求,而不需要收到之前的响应数据。</p><p>首先我们来看看<strong>几种模型的样例代码</strong>:</p><p><strong>1.同步IO</strong>。db.writeSync()方法是同步阻塞的。函数阻塞,直至收到响应数据。因此,调用者一次只能提交一个请求,必须等待该请求返回,才能再提交下一个请求。</p><pre><code>/* 提交请求并阻塞,直至收到响应数据*/
String result = db.writeSync("data");
process(result);
</code></pre><p><strong>2.基于线程池(ExecutorService)的异步 IO</strong>。db.writeSync() 方法不变;但是将其提交到线程池中来执行,使得调用者线程不会阻塞,从而可以连续提交多条请求data1-3。</p><p>提交请求后,线程池返回 Future 对象,调用者调用 Future.get() 以获取响应数据。Future.get() 方法却是阻塞的,因此调用者在获得响应数据之前无法再提交后续请求。</p><pre><code>/* 提交请求*/
// executor: ExecutorService
Future<String> resultFuture1 = executor.submit(() -> db.writeSync("data1"));
Future<String> resultFuture2 = executor.submit(() -> db.writeSync("data2"));
Future<String> resultFuture3 = executor.submit(() -> db.writeSync("data3"));
/* 获取响应:同步*/
String result1 = resultFuture1.get();
String result2 = resultFuture2.get();
String result3 = resultFuture3.get();
process(result1);
process(result2);
process(result3);</code></pre><p><strong>3.基于 Promise 的异步IO</strong>。db.writeAsync()方法是纯异步的,提交请求后返回 Promise 对象;调用者调用 Promise.await() 注册回调,当收到响应数据后触发回调。</p><p>在<strong>《原理篇》</strong>中,我们看到了 Promise API 可以基于线程池或响应式模型实现;不论哪种方式,回调函数可以在接收响应的线程中执行,而不需要调用者线程阻塞地等待响应数据。</p><pre><code class="js">/* 提交请求*/
Promise<String> resultPromise1 = db.writeAsync("data1");
Promise<String> resultPromise2 = db.writeAsync("data2");
Promise<String> resultPromise3 = db.writeAsync("data3");
/* 获取响应:异步*/
resultPromise1.await(result1 -> process(result1));
resultPromise2.await(result2 -> process(result2));
resultPromise3.await(result3 -> process(result3));
</code></pre><p>接下来我们看看以上几种模型中,调用者线程状态随时间变化的过程,如图2-1所示。</p><blockquote><p>a.<strong>同步 IO</strong>。调用者一次只能提交一个请求,在收到响应之前不能提交下一个请求。</p><p>b.<strong>基于线程池的异步 IO</strong>。同一组请求(请求1-3,以及请求4-6)可以连续提交,而不需要等待前一条请求返回。然而,一旦调用者使用 Future.get() 获取响应数据(result1-3),就会阻塞而无法再提交下一组请求(请求4-6),直至实际收到响应数据。</p><p>c.<strong>基于 Promise 的异步 IO。</strong> 调用者随时可以提交请求,并向 Promise 注册对响应数据的回调函数;稍后接收线程向 Promise 通知响应数据,以触发回调函数。上述过程中,调用者线程不需要等待响应数据,始终不会阻塞。</p></blockquote><p><img src="/img/bVcTCcE" alt="" title=""></p><p><em>图2-1a 线程时间线:同步IO</em></p><p><img src="/img/bVcTCcG" alt="" title=""></p><p><em>图2-1b 线程时间线:基于线程池的异步 IO</em></p><p><img src="/img/bVcTCcH" alt="" title=""></p><p><em>图2-1c 线程时间线:基于 Promise 的异步 IO</em></p><h3>2.2 Promise 结合线程池</h3><p>和 ExecutorService+Future 相比,Promise 具有纯异步的优点;然而在某些场景下也需要把 Promise 和线程池结合使用。例如:1.底层 API 只支持同步阻塞模型,不支持纯异步;此时只能在线程池中调用 API,才能做到非阻塞。2.需要重构一段遗留代码,将其线程模型从线程池模型改为响应式模型;可以先将对外接口改为 Promise API,而底层实现暂时使用线程池。</p><p>下面的代码片段展示了 <strong>Promise 和线程池结合的用法:</strong></p><blockquote><ol><li>创建 Promise 对象作为返回值。注意这里使用了 PromiseOrException,以防期间遇到异常;其可以通知响应数据,也可以在失败时通知抛出的 Exception。详见3.1小节。</li><li>在线程池中执行请求(2a),并在收到响应数据后向 Promise 通知(2b)</li><li>处理线程池满异常。线程池底层关联一个 BlockingQueue 来存储待执行的任务,一般设置为有界队列以防无限占用内存,当队列满时会丢弃某个任务。为了向调用者通知该异常,线程池的拒绝策略须设置为 AbortPolicy,当队列满时丢弃所提交的任务,并抛出RejectedExecutionException;一旦捕获该异常,就要向 Promise 通知请求失败。</li></ol></blockquote><pre><code class="js">public PromiseOrException<String, Exception> writeAsync() {
// 1.创建Promise对象
PromiseOrException<String, Exception> resultPromise = new PromiseOrException<>();
try {
executor.execute(() -> {
String result = db.writeSync("data"); // 2a.执行请求。只支持同步阻塞
resultPromise.signalAllWithResult(result); // 2b.通知Promise
});
}catch (RejectedExecutionException e){ // 3.异常:线程池满
resultPromise.signalAllWithException(e);
}
return resultPromise;
}</code></pre><h2>3.异常处理:PromiseOrException</h2><p>Java 程序有时会遇到不可避免的异常情况,如网络连接断开;因此,程序员需要设计适当的异常处理机制,以提升程序的容错性。本章介绍异步 API 的异常处理,首先介绍 Java 语言异常处理规范;然后介绍 Promise 的变体 PromiseOrException,使得 Promise API 支持规范的异常处理。</p><h3>3.1异常处理规范</h3><p>个人认为,<strong>Java 代码的异常处理</strong>应当符合下列规范:</p><blockquote><ol><li>显式区分正常出口和异常出口。</li><li>支持编译时刻检查,强制调用者处理不可避免的异常。</li></ol></blockquote><p> </p><p><strong><em>区分正常出口和异常出口</em></strong></p><p>异常是 Java 语言的重要特性,是一种基本的控制流。Java 语言中,一个函数允许有一个返回值,以及抛出多个不同类型的异常。函数的返回值是正常出口,函数返回说明函数能够正常工作,并计算出正确的结果;相反,一旦函数遇到异常情况无法继续工作,如网络连接断开、请求非法等,就要抛出相应的异常。</p><p>虽然 if-else 和异常都是控制流,但是程序员必须<strong>辨析二者的使用场景</strong>。if-else 的各个分支一般是对等的,都用于处理正常情况;而函数的返回值和异常是不对等的,抛出异常表示函数遇到无法处理的故障,已经无法正常计算结果,其与函数正常工作所产生的返回值有本质区别。在 API 设计中,混淆正常出口(返回值)与异常出口(抛出异常),或者在无法继续工作时不抛异常,都是严重的设计缺陷。</p><p>以数据库访问为例,下面的代码对比了 API 进行异常处理的两种形式。数据库访问过程中,如果网络连接顺畅,并且服务端能够正确处理请求,那么 db.write() 应该返回服务端的响应数据,如服务端为所写数据生成的自增id、条件更新实际影响的数据条数等;如果网络连接断开,或者客户端和服务端版本不匹配导致请求无法解析,从而无法正常工作,那么 db.write() 应该抛出异常以说明具体原因。从“是否正常工作”的角度看,上述两种情况的性质是截然不同的,显然应该选用异常作为控制流,而不是 if-else。</p><pre><code class="js">/* 正确*/
try {
String result = db.write("data");
process(result); // 正常出口
} catch (Exception e) {
log.error("write fails", e); // 异常出口
}
/* 错误*/
String resultOrError = db.write("data");
if (resultOrError.equals("OK")) {
process(resultOrError); // 正常出口
} else {
log.error("write fails, error: " + resultOrError); // 异常出口
} </code></pre><p><strong><em>强制处理不可避免的异常</em></strong></p><p>Java 语言的异常处理体系中,<strong>异常主要分为以下几类</strong>:Exception、RuntimeException、Error;三者都是 Throwable 的子类,即可以被函数抛出。注意,由于 RuntimeException 是 Exception 的子类,本文为避免混淆,“Exception”特指“是 Exception 但不是RuntimeException”的那些异常。</p><p>个人认为,<strong>几种异常类型分别用于下列场景</strong>:</p><blockquote><p><strong>1. Exception</strong>:程序外部的不可抗力造成的异常情况,如网络连接断开。即使 Java 代码完美无瑕,也绝对不可能避免这类异常(拔掉网线试试!)。既然无法避免,这种异常就应当强制处理,以提升系统的容错能力。<br><strong>2. RuntimeException</strong>:编程错误造成的异常情况,如数组下标越界 ArrayOutOfBoundException、参数不符合取值范围 IllegalArgumentException 等。如果程序员对 API 的输入约束了如指掌,并在调用 API 之前对函数参数进行适当校验,那么 RuntimeException 是可以绝对避免的(除非被调 API 在应当抛 Exception 处,实际抛出了RuntimeException)。既然可以避免,这种异常就没有必要强制处理。</p><p>当然,人无完人。假设程序员真的违背了某些约束,函数抛出 RuntimeException 且未被处理,那么作为惩罚,线程或进程会退出,从而提醒程序员改正错误代码。如果线程或进程必须常驻,就要对 RuntimeException 进行兜底,如下面的代码所示。这里将代码缺陷视为无法避免的异常情况,捕获异常后可以记录日志、触发告警,提醒稍后来修正缺陷。</p><pre><code class="js">new Thread(()->{
while (true){
try{
doSomething();
}catch (RuntimeException e){ // 对RuntimeException进行兜底,以防线程中断
log.error("error occurs", e);
}
}
});</code></pre><p><strong>3. Error:jvm内部定义的异常</strong>,如 OutOfMemoryError。业务逻辑一般不抛出 Error,而是抛出某种 Exception 或 RuntimeException。</p></blockquote><p>上述几类异常中,只有 Exception 是强制处理的,称为 checked exception[C]。如下所示是一个 checked exception的例子。数据库访问DB.write()抛出 Exception,表示遇到网络断开、消息解析失败等不可抗情况。异常类型为 Exception 而不是 RuntimeException,以强制调用者添加catch子句处理上述情况;如调用者遗漏了 catch 子句,则编译器会报错,从而提示调用者“这里一定会遇到异常情况,必须进行处理”,以完善程序容错能力。</p><pre><code class="js"> /**
* 抛出异常,如果:
* 1.网络连接断开
* 2. 消息无法解析
* 3. 业务逻辑相关,如服务端扣款时发现余额不足
* 4. …… // 任何无法避免的情况,都应该抛出Exception!
*/
public String write(Object data) throws Exception {
return "";
}
/**
* 处理异常
*/
try {
String result = db.write("data");
process(result);
} catch (Exception e) { // 如遗漏catch子句,则编译不通过
log.error("write fails, db: ..., data: ...", e);
}</code></pre><h3>3.2 Promise API的异常处理</h3><p>上一小节讨论了异常处理的规范:</p><ul><li>显式区分正常出口和异常出口;</li><li>不可抗的异常,要在编译时刻强制处理。下面的代码展示了 Promise API 要如何设计异常处理机制,以符合上述规范。</li></ul><blockquote><ol><li><strong>使用PromiseOrException来通知响应数据和异常</strong>。PromiseOrException<T, E>是Promise<X>的子类,泛型模版X为数据对象ResultOrException<T, E extends Exception>,其含有2个字段 result 和 e:e==null表示正常,此时字段result有效;e!=null表示异常,此时不要使用字段 result。</li><li><strong>在“重载1”中,调用者从回调函数中获得ResultOrException对象</strong>。调用ResultOrException.get()获取响应数据result,或者get()方法抛出异常e。这种方式的代码结构和传统的异常处理一致,可以使用多个catch子句分别处理不同类型的异常。</li><li><strong>在“重载2”中,调用者从回调函数中直接获得result和e</strong>。含义同上。这种方式省去了ResultOrException.get();但是如果需要处不同类型的异常,则需要用e instanceof MyException来判断异常类型。</li></ol></blockquote><pre><code class="js">// extends Promise<ResultOrException<String, Exception>>
PromiseOrException<String, Exception> resultPromsie = db.writeAsync("data");
/* 重载1*/
resultPromsie.await(resultOrException -> {
try {
String result = resultOrException.get();
process(result); // 正常出口
} catch (Exception e) {
log.error("write fails", e); // 异常出口
}
});
/* 重载2*/
resultPromsie.await((result, e) -> {
if (e == null) {
process(result); // 正常出口
} else {
log.error("write fails", e); // 异常出口
}
});</code></pre><p>PromiseOrException 符合上一小节提出的异常处理规范,<strong>具有如下优点</strong>:</p><ol><li>区分正常出口和异常出口。响应数据和异常分别使用 result 和 e 两个变量来传递,可以靠e==null来判断是否正常。注意result==null不能作为判断条件,因为 null 有可能是响应数据的合法值。</li><li>强制处理异常。不论使用哪一种回调,不存在一种代码结构能够只获得 result 而不获得e,因此语法上不会遗漏e的异常处理。</li><li>允许定义异常类型。PromiseOrException 的泛型模版E填为 Excetion 不是必需的,也可以填为任意其他类型。注意,受限于 Java 语法,泛型模版处只允许填写一种异常类型,而不像函数抛异常那样允许抛出多种异常。为应对这种限制,我们只能为 API 定义一个异常父类,调用者用 catch 子句或 instanceof 进行向下转型。当然,这种“定义异常父类”的做法也是可以接受的,并在现有工具中广泛应用,因为可以将工具所抛异常区别于Java语言内置的异常类型。</li></ol><p>最后,在异常处理结构方面个人提出<strong>一个建议</strong>:全部异常通过 PromiseOrException 来通知,而 API 本身不要抛出异常。以数据库访问 API writeAsync()为例,面的代码对比了两种抛异常的方式。<strong>正确的做法</strong>是PromiseOrException 作为唯一出口,如果 API 底层实现抛出异常(submit() throws Exception),则应该将异常封装于 PromiseOrException 对象,而不应该直接从 API 函数抛出(writeAsync() throws Exception)。</p><pre><code class="js">/* 正确:唯一出口PromiseOrException*/
public PromiseOrException<String, Exception> writeAsync(Object data) {
try {
submit(data); // throws exception
} catch (Exception e) {
return PromiseOrException.immediatelyException(e);
}
PromiseOrException<String, Exception> resultPromise = new PromiseOrException<>();
return resultPromise;
}
/* 错误:两个出口throws Exception和PromiseOrException*/
public PromiseOrException<String, Exception> writeAsync(Object data) throws Exception {
submit(data); // throws exception
PromiseOrException<String, Exception> resultPromise = new PromiseOrException<>();
return resultPromise;
}</code></pre><p>如果错误地设计了含有两个异常出口的 API,调用者就不得不重复书写异常处理逻辑,如下面的代码所示。</p><pre><code class="js">try {
PromiseOrException<String, Exception> resultPromise = db.writeAsync("data");
resultPromise.await((result, e) -> {
if (e == null) {
process(result); // 正常出口
} else {
log.error("write fails", e); // 异常出口2
}
});
} catch (Exception e) {
log.error("write fails", e); // 异常出口1
}</code></pre><h2>4.请求调度</h2><p>Java 程序中有时需要提交多条异步请求,且这些请求之间存在一定的关联关系。在异步非阻塞场景下,这些关联关系都可以借助 Promise 来实现。</p><p><strong>1.顺序请求</strong>,如图4-1所示。后一条请求依赖前一条请求的响应数据;因此,必须等待前一条请求返回,才能构造并提交下一条请求。</p><p><img src="/img/bVcTCc5" alt="" title=""></p><p><em>图4-1 顺序请求</em></p><p><strong>2.并行请求</strong>,如图4-2所示。一次提交多条请求,然后等待全部请求返回。所提交的请求之间没有依赖关系,因此可以同时执行;但是必须收到每条请求的响应数据(发生channelRead()事件,事件参数为响应数据),才能执行实际的处理process(result1,2,3)。</p><p><img src="/img/bVcTCc6" alt="" title=""><br><em>图4-2 并行请求</em></p><p><strong>3.批量请求</strong>,如图4-3所示。调用者连续提交多条请求,但是暂存在队列中(offer()),而不是立刻执行。一段时间后,从队列中取出若干条请求,组装为批量请求来提交(writeBulk());当收到批量请求的响应消息时,可以从中取出每条请求的响应数据。由于每次网络 IO 都带来额外开销,故实际应用中经常使用批量请求来减少网络 IO 频率,以提升总体吞吐量。</p><p><img src="/img/bVcTCc9" alt="" title=""><br><em>图4-3 批量请求</em></p><h3>4.1.顺序请求:Promise.then()</h3><p>假设一系列操作需要依次完成,即前一操作完成后,才能开始执行下一操作;如果这些操作均表现为 Promise API,我们可以对 Promise.await(listener)进行封装,使代码结构更加简洁。</p><p>如下所示是一个异步 Promise API。submit 方法提交请求 request 并返回 Promise 对象;当收到响应数据时, 该 Promise 对象被通知。</p><pre><code class="js">/**
* 异步Promise API
*/
public static Promise<String> submit(Object request) {
Promise<String> resultPromise = new Promise<>();
// ……
return resultPromise;
}</code></pre><p>现假设有5个请求称为“A”-“E”,这些请求必须依次提交。例如,由于请求B的参数依赖请求 A 的响应数据,故提交请求A后必须先处理其响应数据 resultA,然后才能再提交请求B。这种场景可以用如下所示的代码来实现。某次调用submit("X")函数后,我们在其返回的 Promise 对象上注册回调;回调函数内处理响应数据 resultX,并调用submit("X+1")来提交下一请求。</p><p>这种方式虽然能实现功能需求,但是嵌套式的代码结构可读性非常差——每增加一个请求,代码就要多嵌套、缩进一个层级。当调用逻辑复杂、请求数较多时,代码会非常难以维护。</p><p>这种情况也称为 <strong>“回调地狱”</strong>[D],在 JavaScript 语言中相关讨论颇多,可以作为参考。</p><pre><code class="js">submit("A").await(resultA -> {
submit("B").await(resultB -> {
submit("C").await(resultC -> {
submit("D").await(resultD -> {
submit("E").await(resultE -> {
process(resultE);
});
});
});
});
});</code></pre><p>为改进代码结构,我们对 Promise<T>.await(Consumer<T>) 方法进行封装,提供Promise<T>.then(Function<T, Promise<Next>>)方法,如下所示。类似于await(),then()也可以注册一个回调函数resultX->submit("X+1"),回调函数处理响应数据resultX,并提交下一请求submit("X+1");then()的返回值即submit("X+1")的返回值,用于通知下一请求的响应数据resultX+1。</p><pre><code class="js">Promise<String> resultPromiseA = submit("A");
Promise<String> resultPromiseB = resultPromiseA.then(resultA -> submit("B"));
Promise<String> resultPromiseC = resultPromiseB.then(resultB -> submit("C"));
Promise<String> resultPromiseD = resultPromiseC.then(resultC -> submit("D"));
Promise<String> resultPromiseE = resultPromiseD.then(resultD -> submit("E"));
resultPromiseE.await(resultE -> process(resultE));</code></pre><p>接下来,我们将中间变量 resultPromiseA-E 内联,即得到基于then()的链式调用结构。相比于await(),then()消除了套娃般的嵌套回调。</p><pre><code>submit("A")
.then(resultA -> submit("B")) // 返回resultPromiseB
.then(resultB -> submit("C")) // 返回resultPromiseC
.then(resultC -> submit("D")) // 返回resultPromiseD
.then(resultD -> submit("E")) // 返回resultPromiseE
.await(resultE -> process(resultE));</code></pre><p>最后,我们来看一下 <strong>Promise<T>.then() 的一种简单实现</strong>,如下所示:</p><ol><li>then()方法提供一个泛型模版Next,以说明下一请求的响应数据类型。</li><li>根据泛型模版Next,then()内部创建Promise<Next>作为返回值,用于通知下一请求的响应数据。</li><li>对于当前请求,调用await()注册响应数据的回调result;当收到响应数据后,执行函数func,以提交下一请求:func.apply(result)。</li><li>当收到下一请求的响应数据后,Promise<Next>被通知:nextPromise::signalAll。</li></ol><pre><code class="js">public <Next> Promise<Next> then(Function<T, Promise<Next>> func) {
Promise<Next> nextPromise = new Promise<>();
await(result -> {
Promise<Next> resultPromiseNext = func.apply(result);
resultPromiseNext.await(nextPromise::signalAll);
});
return nextPromise;
}</code></pre><p>注意,这里只展示了纯异步重载 Promise<T>.then(Function<T, Promise<Next>>)。根据回调函数是否有返回值、同步执行还是异步执行,Promise 可以提供 then() 的更多种重载;受限于Java 语法,如编译器无法辨析各个重载,则可以使用函数名称进行显式区别,如:</p><p>thenRun(Runnable)</p><p>thenAccept(Consumer<T>)</p><p>thenApply(Function<T, Next>)</p><p>thenApplyAsync(Function<T, Promise<Next>>)</p><h3>4.2.并行请求:LatchPromise</h3><p>上一小节介绍了“顺序请求”的场景,即多条请求需要依次执行;而“并行请求”场景下,多条请求之间没有顺序约束,但是我们仍然需要等待全部请求返回,才能执行后续操作。例如,我们需要查询多张数据库表,这些查询语句可以同时执行;但是必须等待每条查询都返回,我们才能获得完整信息。jdk 提供 CountDownLatch 来实现这一场景,但是其只支持同步等待;作为改进,我们采用LatchPromise 实现相同的功能,并且支持纯异步 API。</p><p>以数据库访问为例,如下所示的代码<strong>展示了LatchPromise的使用</strong>:</p><ol><li>提交3条请求,并获取每个请求所对应的Promise对象resultPromise1-3,以获取响应数据。</li><li>创建LatchPromise对象,并向其注册需要等待的Promise对象resultPromise1-3。</li><li>LatchPromise.untilAllSignaled()返回一个Promise对象allSignaled。当所注册的resultPromise1-3均被通知后,allSignaled会被通知。allSignaled的类型为VoidPromise,表示allSignaled被通知时没有需要处理的响应数据。</li><li>在allSignaled上注册回调,在回调函数中调用resultPromiseX.await()获取实际的响应数据;此时由于请求已执行完毕,故await()立刻返回而不阻塞。</li></ol><pre><code class="js">/* 创建Promise对象*/
Promise<String> resultPromise1 = db.writeAsync("a");
Promise<String> resultPromise2 = db.writeAsync("b");
Promise<String> resultPromise3 = db.writeAsync("c");
/* 向LatchPromise注册要等待的Promise*/
LatchPromise latch = new LatchPromise();
latch.add(resultPromise1);
latch.add(resultPromise2);
latch.add(resultPromise3);
/* 等待全部Promise被通知*/
VoidPromise allSignaled = latch.untilAllSignaled();
allSignaled.await(() -> {
String result1 = resultPromise1.await();
String result2 = resultPromise2.await();
String result3 = resultPromise3.await();
process(result1, result2, result3);
});</code></pre><p>作为对比,下面的代码使用 CountDownLatch 实现相同功能,但是<strong>存在以下缺陷</strong>:</p><blockquote><ol><li>CountDownLatch.await() 只支持同步等待。在纯异步场景下是无法接受的。</li><li>CountDownLatch 对业务逻辑有侵入性。程序员需要在业务逻辑中添加对 CountDownLatch.countDown()的调用,以控制CountDownLatch 的时序;相反,LatchPromise 依赖本来就已经存在的 resultPromise 对象,而不需要编写额外的时序控制代码。</li><li>CountDownLatch 引入了冗余逻辑。创建 CountDownLatch 时,必须在构造参数中填写要等待的请求数;因此,一旦所提交的请求的数目改变,就必须相应地更新创建 CountDownLatch 的代码,修改构造参数。</li></ol></blockquote><pre><code class="js">CountDownLatch latch = new CountDownLatch(3);
resultPromise1.await(result1 -> latch.countDown());
resultPromise2.await(result2 -> latch.countDown());
resultPromise3.await(result3 -> latch.countDown());
latch.await();
String result1 = resultPromise1.await();
String result2 = resultPromise2.await();
String result3 = resultPromise3.await();
process(result1, result2, result3); </code></pre><p>最后,我们来看一下<strong> LatchPromise 的参考实现</strong>。代码原理如下所示:</p><ol><li><strong>设立countUnfinished变量,记录还没有被通知的Promise对象的数目</strong>。每当注册一个Promise对象,countUnfinished递增;每当一个Promise被通知,countUnfinished递减。当countUnfinished减到0时,说明所注册全部Promise对象都被通知了,故通知allSignaled。</li><li><strong>设立noMore变量</strong>,记录是否还需要继续注册新的Promise对象,仅当调用了untilAllSignaled()才认为完成注册;在此之前,即使countUnfinished减至0,也不应该通知allSignaled。考虑这样一种情况:需要注册并等待resultPromise1-3,其中resultPromise1、2在注册期间即已被通知,而resultPromise3未被通知。如果不判断noMore,那么注册完resultPromise1、2后,countUnfinished即已减至0,导致提前通知allSignaled;这是一个时序错误,因为实际上resultPromise3还没有完成。</li><li><strong>为保证线程安全,访问变量时须上锁</strong>,此处使用synchronized来实现。</li><li><strong>注意</strong>,调用untilAllSignaled()时,如果countUnfinished的初值已经为0,则应立刻通知allSignaled;因为countUnfinished已经不可能再递减,之后没有机会再通知allSignaled了。</li></ol><pre><code class="js">// private static class Lock。无成员,仅用于synchronized(lock)
private final Lock lock = new Lock();
private int countUnfinished = 0;
private final VoidPromise allSignaled = new VoidPromise();
public void add(Promise<?> promise) {
if (promise.isSignaled()) {
return;
}
synchronized (lock) {
countUnfinished++;
}
promise.await(unused -> {
synchronized (lock) {
countUnfinished--;
if (countUnfinished == 0 && noMore) {
allSignaled.signalAll();
}
}
});
}
public VoidPromise untilAllSignaled() {
synchronized (lock) {
if (countUnfinished == 0) {
allSignaled.signalAll();
} else {
noMore = true;
}
}
return allSignaled;
}</code></pre><h3>4.3.批量请求:ExecutorAsync</h3><p><strong><em>批量请求的特性</em></strong></p><p>“批量请求”(也称“bulk”、“batch”)是指发送一条消息即可携带多条请求,主要用于数据库访问和远程调用等场景。由于减少了网络 IO 次数、节约了构造和传输消息的开销,批量请求能有效提升吞吐量。</p><p>很多数据库 API 都支持批量读写,如JDBC PreparedStatement[E]、elasticsearch bulk API[F]、mongo DB insertMany()[G]、influx DB BatchPoints[H],读者可以查阅参考文献进一步了解。为了提升性能,部分API会牺牲易用性。其中,elasticsearch bulk API 对调用者的限制最少,允许混杂增删改等不同类型的请求,允许写入不同的数据库表(index);mongo DB、influx DB 次之,一个批量请求只能写入同一个数据库表,但是可以自定义每条数据的字段;PreparedStatement 的灵活性最低,其定义了 SQL 语句的模版,调用者只能填写模版参数,而不能修改语句结构。</p><p>虽然数据库 API 已经支持批量访问,但是很多原生 API 仍然需要调用者自己构造批量请求,需要调用者处理请求组装、批量大小、并发请求数等复杂的细节。</p><p>在此,我们设计出通用组件 ExecutorAsync,封装请求调度策略以提供更简洁的API。<strong>ExecutorAsync 的使用流程</strong>如下面的代码片段所示:</p><ol><li>类似于线程池 ExecutorService.submit(),调用者可以调用ExecutorAsync.submit()来提交一个请求。其中,请求以数据对象 Request 表示,用于存储请求类型和请求参数。</li><li>提交请求后,调用者获得 Promise 对象,以获取响应数据。由于使用了 Promise,ExecutorAsync 支持纯异步操作,提交请求和获取响应数据都不需要阻塞。</li><li>ExecutorAsync 内部对请求进行调度,并非提交一条请求就立刻执行,而是每隔固定时间收集一批请求,将其组装为一个批量请求,再调用实际的数据库访问 API。如果数据库访问 API 允许,那么一批请求可以混杂不同的请求类型,或者操作不同的数据库表。</li></ol><pre><code class="js">ExecutorAsync executor = new ExecutorAsync();
Promise<...> resultPromise1 = executor.submit(new Request("data1"));
Promise<...> resultPromise2 = executor.submit(new Request("data2"));
Promise<...> resultPromise3 = executor.submit(new Request("data3"));</code></pre><p>具体而言,ExecutorAsync 支持如下调度策略:</p><p><strong>1.排队,</strong>如图4-4a所示。调用者提交请求Request后不要立刻执行,而是将其缓存在队列queue中。</p><p><img src="/img/bVcTCdk" alt="" title=""><br><em>图4-4a ExecutorAsync特性:排队</em></p><p><strong>2.批量</strong>,如图4-4b所示。每隔固定时间间隔,ExecutorAsync从队列中取出若干条请求,将其组装为批量请求bulk,并调用底层数据库API提交给服务端。如果队列长度增长得很快,我们也可以定义一个批量大小bulk size,当队列长度到达该值时立刻组装一个批量请求并提交。</p><p><img src="/img/bVcTCdn" alt="" title=""><br><em>图4-4b ExecutorAsync特性:批量</em></p><p><strong>3.并发</strong>,如图4-4c所示。如果底层数据库API支持异步提交请求,那么ExecutorAsync就可以充分利用这种特性,连续提交多个批量请求,而不需要等待之前的批量请求返回。为避免数据库服务器超载,我们可以定义并发度parallelism,限制正在执行(in flight)的批量请求的数目;当达到限制时,如果调用者再提交新的请求,就暂存在队列queue中等待执行,而不会组装新的批量请求。</p><p><img src="/img/bVcTCdp" alt="" title=""><br><em>图4-4c ExecutorAsync特性:并发</em></p><p><strong>4.丢弃</strong>。如图4-4d所示。在上文提到的bulk size和parallelism的限制下,如果提交请求的速率远高于服务端响应的速率,那么大量请求就会堆积在队列中等待处理,最终导致超时失败。在这种情况下,将请求发送给服务端已经没有意义,因为调用者已经认定请求失败,而不再关心响应数据。</p><p><img src="/img/bVcTCdq" alt="" title=""></p><p><em>图4-4d 请求超时</em></p><p>因此,ExecutorAsync应该及时从队列中移除无效请求,而剩余请求仍然“新鲜”。这种策略能够强制缩短队列长度,以降低后续请求在队列中的堆积时长、预防请求超时;同时,由于避免存储和发送无效请求,这种策略也能节约内存和 IO 开销。</p><p><img src="/img/bVcTCdr" alt="" title=""></p><p><em>图4-4e ExecutorAsync特性:丢弃</em></p><p><strong><em>批量请求的实现</em></strong></p><p>上一小节我们看到了 ExecutorAsync 的调度策略,包括排队、批量、并发、丢弃。如下面的代码所示,ExecutorAsync只需对外提供 submit(Request) 方法,用于提交单条请求。请求以数据对象 Request 表示,其字段 Request.resultPromise 是 Promise 对象,用于通知响应数据;在需要进行异常处理的场景下,我们使用 PromiseOrException<T, Exception>作为 Promise 的实现,其中泛型模版T改为响应数据的实际类型。</p><pre><code class="js">public class ExecutorAsync {
public PromiseOrException<T, Exception> submit(Request<T> request) {
return request.resultPromise;
}
}</code></pre><p>接下来我们来看看 ExecutorAsync 的实现原理。由于源码细节较多、篇幅较长,故本节用流程图的形式,来讲解更高层的设计,如图4-5所示。</p><p><img src="/img/bVcTCds" alt="" title=""></p><p><em>图4-5 ExecutorAsync原理</em></p><p><strong>1.提交请求</strong>。调用者调用 ExecutorAsync.submit(Request),每次调用提交一条请求。该条请求存入队列 queue,等待后续调度执行。参数 Request 的结构如下面的代码所示,包括下列字段:</p><p>predicate:函数,判断请求是否有效,无效请求(如超时的请求)将被丢弃。详见步骤2。</p><p>resultPromise:通知响应数据。</p><pre><code class="js">public class Request<Response> {
public final PredicateE predicate;
public final PromiseOrException<Response, Exception> resultPromise;
}</code></pre><p><strong>2.每隔固定间隔,或者queue.size()达到bulk size,尝试组装批量请求</strong>。从队列queue中依次取出请求,每条请求执行函数Request.predicate,以判断是否仍然要提交该请求;取出的有效请求的条数,不超过bulk size。<br>predicate是一个函数,类似于jdk Predicate接口,形式如下面的代码所示。接口函数test()可以正常返回,表示请求仍然有效;也可以抛出异常,说明请求无效的原因,如等待超时。如果抛出异常,则该条请求直接丢弃,并将发生的异常将通知给Request.resultPromise,使得调用者执行异常处理逻辑。</p><pre><code class="js">public interface PredicateE {
void test() throws Exception;
}</code></pre><p><strong>3.提交批量请求</strong>。第2步从队列queue中取出了至多bulk size条请求,将其作为参数调用RequestFunc.execute(requests),以提交批量请求。接口RequestFunc的形式如下面的代码所示。接口方法execute(requests)以若干条请求为参数,将其组装为批量请求,调用底层的数据库API来提交。</p><pre><code class="js">public interface RequestFunc<T>{
void execute(List<Request<T>> requests);
}</code></pre><p><strong>4.当收到响应后,对于每条请求,依次向 Request.resultPromise 通知响应数据</strong>。</p><p><strong>5.为防止服务端超载,ExecutorAsync可限制并发请求数不超过 parallelism</strong>。我们设置计数变量 inFlight=0,以统计正在执行的批量请求数:</p><blockquote><p>a.当尝试组装批量请求(步骤2)时,首先判断inFlight<parallelism,满足条件才能从队列queue中取出待执行的请求。</p><p>b.当提交批量请求(步骤3,RequestFunc.execute())后,inFlight++。</p><p>c.当一批请求均收到响应数据(步骤4,Request.resultPromise被通知)后,inFlight--。此时如queue中的请求数仍超过 bulk size,则回到步骤2,再取出一批请求来执行。</p><p>综上,ExecutorAsync 使用队列 queue 来暂存待执行的请求;当需要提交批量请求时,以PredicateE筛选有效请求、丢弃无效请求;对于一批请求,调用 RequestFunc.execute()来批量提交,收到响应数据后 Request.resultPromise 通知。上述过程满足约束,以防服务端超载:一批请求的数目至多为 bulk size;同时正在执行的批量请求数不超过 parallelism。以上即为ExecutorAsync 的基本原理;实际应用中还需要处理配置参数、泛型模版等细节,限于篇幅原因本文不再详细讲解。</p></blockquote><h2>5.总结</h2><p>本文介绍了<strong>异步模型</strong>和<strong>Promise设计模式</strong>的实际应用场景,讨论了异步API的设计原则,并介绍了相应的解决方案。异步模式不仅仅是“提交请求-处理响应”的简单过程,有时也需要设计异常处理机制,以及根据请求之间的关联关系进行调度。在处理这些复杂场景的时候,API需要保持纯异步的特性,在提交请求、处理响应的过程中都不能阻塞;需要充分利用编译时刻检查,防止调用者遗漏分支,尤其是不可避免的异常分支;API需要封装复杂的、重复的实现细节,尽量保持调用者的代码结构简洁易懂。</p><p>本系列文章旨在对异步模式进行科普,希望能起到抛砖引玉的作用,帮助读者理解异步模式的基本原理,对有可能遇到的实际问题有所了解,并初步探索异步模式的实现机制。然而,现实中的项目和工具远比本文介绍的复杂,还请读者做好调研、选型工作。考察现有的各种异步API,读者会发现异步模式目前仍无统一标准。以数据库客户端为例,各种异步API的函数形式都不尽相同,listener API和Promise API都有采用,也有一些API形式上是异步的,但是在某些情况下会发生阻塞;异步API底层有基于线程池/连接池实现的,也有基于响应式模型(如netty)实现的。因此,请读者务必充分了解异步工具的API形式、阻塞特性、线程模型,然后才能在项目中应用;如果现有工具不符合开发规范,亦可大胆地进行封装,或者自行实现所需特性。关于异步工具的封装和实现,也非常欢迎读者交流与指正。</p><h2>参考文献</h2><p>[A] jdk CompletableFuture</p><p><a href="https://link.segmentfault.com/?enc=S4Dltnmrbg5pRA7kvDoWcg%3D%3D.Vv6SCpVwoFCIww9KT8vaDC2RCc%2B68cwBHrA8EiT6q9vJFnkGB0zpv5d2NDbi0%2B90" rel="nofollow">https://www.baeldung.com/java...</a></p><p>[B] netty DefaultPromise</p><p><a href="https://link.segmentfault.com/?enc=MVXB%2FQfnz0l0YLqzxJjLGQ%3D%3D.20l28XHq8K7RrVkBkladoz41pleyvwlIqfjKTC9wcG4VE0%2FjPn6vtH2jHz7qXIYll3KFqecGTdmcTw92Kh1P8LJ1dAenvvj70luB5iUkicV4rpCF75FeWI5%2B4w%2FF4WHN" rel="nofollow">https://www.tabnine.com/code/...</a> </p><p>[C] checked exception</p><p><a href="https://link.segmentfault.com/?enc=aNK9C4ZwiWaI4%2BXFrX0u5g%3D%3D.pn7TYyeyk3KuVDKL83xP%2FQTvABWjeVwvYp525O1sXcqQoIWx0HzHT%2BHAXywsOp7y11DBhPWZwjYO6lS2kYTDO%2FpyV%2B3Y%2BBOcLlCehx%2BFkok%3D" rel="nofollow">https://www.geeksforgeeks.org...</a> </p><p>[D] 回调地狱(JavaScript)</p><p><a href="https://link.segmentfault.com/?enc=boefECxKAflKQr%2BwCpWG8Q%3D%3D.lqPLZWZxAQGYAls8Ot7k0Migb5LdIpMVimMflfOK6ZkXfyrOY69BGt%2Badcbz8x2Z%2FqbA9poeEkWKJ3h245oJpz%2BjftvN1ft6LKMJ3iFwJF4%3D" rel="nofollow">https://blog.avenuecode.com/c...</a> </p><p>[E] 批量请求:JDBC PreparedStatement</p><p><a href="https://link.segmentfault.com/?enc=llxpu6kkdw0CmAu14RZYtg%3D%3D.eE4W8mC9Fd2VFxcNeKpD6Yb3xI8%2BSu0cOvRQe36rdFJxOUheD4PqvgaFIdHcJQUwWFYiYRAyFMthGMN7GPhT8Q%3D%3D" rel="nofollow">https://www.tutorialspoint.co...</a> </p><p>[F] 批量请求:elasticsearch bulk API</p><p><a href="https://link.segmentfault.com/?enc=qxz4mYi03mEmuRSgRDP01g%3D%3D.H16BuAFI67SaYlcL1pV8ZxGxSkimGe2uxeoYGi%2FO9MN%2Fq3NdY%2BdjcXxdtAscPSx8bsz1nspvm%2Bls8%2BiZKPYQfUGsI%2BAVZhqiLexyos3TIu8%3D" rel="nofollow">https://www.elastic.co/guide/...</a> </p><p>[G] 批量请求:mongo DB insertMany()</p><p><a href="https://link.segmentfault.com/?enc=qZT2ZwORKFFztW%2FDCJ1R0g%3D%3D.g6a68qnsjABC1jc5UgnUA0s8c3mLucSIna0svyVxDKxUMrPNSggpYKz1V6AlpXCrBwDLnyB%2B2%2BwKUMviRZIRCDjXNNUGPhSwGwzar3ECjBCDtq917U3GKllSfcVnTgAY" rel="nofollow">https://mongodb.github.io/mon...</a> </p><p>[H] 批量请求:influx DB BatchPoints</p><p><a href="https://link.segmentfault.com/?enc=tTTnvg9oyWwlvVFZiNf%2F%2Fw%3D%3D.kMTwTmNpUNiN9PcCvspHq3j7uJ72hCKSIFtlP%2Bk%2BsTVgtcr6GMr9lpn0HK7L92g8OKKHJ4bQ8%2FGgdoe%2Bbovk9pVtOGjvsoGWyg%2BGJu23YAM%3D" rel="nofollow">https://github.com/influxdata...</a></p>
JAVA语言异步非阻塞设计模式(原理篇)
https://segmentfault.com/a/1190000040347139
2021-07-14T16:25:13+08:00
2021-07-14T16:25:13+08:00
有道AI情报局
https://segmentfault.com/u/youdaotec
4
<p><img src="/img/remote/1460000040347141" alt="封面2.png" title="封面2.png"></p><p>本系列文章共2篇,对 Java 语言的异步非阻塞模式进行科普。《原理篇》讲解异步非阻塞模型的原理,以及核心设计模式“Promise”的基本特性。《应用篇》会展示更加丰富的应用场景,介绍 Promise 的变体,如异常处理、调度策略等,并将 Promise 和现有工具进行对比。</p><p>限于个人水平和篇幅,本系列以科普为主,内容更偏重于原理、API 设计、应用实践,但是不会深入讲解并发优化的具体细节。</p><h2>1.概述</h2><p>异步非阻塞[A]是一种高性能的线程模型,在 IO 密集型系统中得到广泛应用。在该模型下,系统发起耗时请求后不需要等待响应,期间可以执行其他操作;当收到响应后,系统收到通知并执行后续处理。由于消除了不必要的等待,这种模型能够充分利用 cpu、线程等资源,提高资源利用率。</p><p>然而,异步非阻塞模式在提升性能的同时,也带来了编码实现上的复杂性。请求和响应可能分离到不同线程中,需要编写额外代码完成响应结果的传递。Promise 设计模式可以降低这种复杂性,封装数据传递、时序控制、线程安全等实现细节,从而提供简洁的 API 形式。</p><p>本文首先介绍异步非阻塞模式,从线程模型的角度分析阻塞和非阻塞模式的区别。之后介绍 Promise 设计模式的应用场景及工作流程。最后,提供一种简易的 Java 实现,能够实现基本的功能需求,并做到线程安全。</p><p>在正式探索技术问题之前,我们先来看看什么是<strong>异步非阻塞模型</strong>。如图1-1所示,展示了两个小人通信的场景:</p><ol><li>两个小人代表互相通信的两个<strong>线程</strong>,如数据库的客户端和服务端;他们可以部署在不同的机器上。</li><li>小人之间互相投递苹果,代表要<strong>传递的消息</strong>。根据具体业务场景,这些消息可能会称为 request、response、packet、document、record 等。</li><li>小人之间需要<strong>建立信道</strong>,消息才得以传递。根据场景,信道称为 channel、connection 等。</li></ol><p>假设左侧小人发起请求,而右侧小人处理请求并发送响应:左侧小人先投出一个苹果request,被右侧小人接收到;右侧小人进行处理后,再投出苹果 response,被左侧小人接收到。我们考察左侧小人在等待响应期间的行为,根据他在等待 response 期间是否能处理其他工作,将其归纳为“同步阻塞”和“异步非阻塞”两种模式。</p><p><img src="/img/remote/1460000040347142" alt="1-1.png" title="1-1.png"><br><em>图1-1 两个小人通信</em></p><p>首先我们看看同步阻塞式通信的流程,如图1-2a所示。</p><ol><li><strong>投递</strong>。左侧小人投递 request,并等待接收 response。</li><li><strong>等待</strong>。在等待接收 response 期间,左侧小人休息。不论是否还有其他 request需要投递、是否还有其他工作需要处理,他都视若无睹,绝对不会因此打断休息。</li><li><strong>响应</strong>。在收到 response 后,小人从休息中唤醒并处理 response。</li></ol><p><img src="/img/remote/1460000040347143" alt="1-2.png" title="1-2.png"><br><em>图1-2a 同步阻塞式通信</em></p><p>接下来我们看看异步非阻塞式通信的流程,如图1-2b所示。</p><blockquote><ol><li><strong>缓存</strong>。左侧小人投递 request,并等待接收 response。和同步阻塞模式不同,小人并不需要亲手接住苹果 response,而是在地上放置一个盘子称为“buffer”;如果小人暂时不在场,那么所收到的苹果可以先存在盘子里,稍后再处理。</li><li><strong>暂离</strong>。由于有盘子 buffer 的存在,小人投递 request 后就可以暂时离开,去处理其他工作,当然也可以去投递下一个 request;如果需要向不同的channel投递request,那么小人可以多摆放几个盘子,和 channel 一一对应。</li><li><strong>响应</strong>。小人离开后,一旦某个盘子收到了 response,一只“大喇叭”就会响起,发出“channelRead”通知,呼唤小人回来处理 response 。如果要处理多个response 或多个 channel,那么 channelRead 通知还需要携带参数,以说明从哪个 channel 上收到了哪个 response。</li></ol></blockquote><p>这里的大喇叭可以用 <strong>NIO 或 AIO </strong>来实现。简单来说,<strong>NIO</strong> 是指不停地轮询每个盘子,一旦看到苹果就发出通知;AIO 是指在收到苹果时直接触发通知,而没有轮询的过程。当然,本系列文章的读者并不需要了解更多实现细节,只需知道异步非阻塞模式依赖于“大喇叭”来实现,它替代小人等待接收 response,从而解放小人去处理其他工作。</p><p><img src="/img/remote/1460000040347144" alt="1-2b.png" title="1-2b.png"><br><em>图1-2b 异步非阻塞式通信</em></p><p>根据上面的分析,同步模式具有下列严重<strong>缺点</strong>:</p><ol><li><strong>同步阻塞模式的工作效率十分低下</strong>。小人大部分时间都在休息,仅当投递请求、处理响应时,才偶尔醒来工作一小会;而在异步非阻塞模式下,小人从不休息,马不停蹄地投递请求、处理响应,或处理其他工作。</li><li><strong>同步阻塞模式会带来延迟</strong>。</li></ol><p>我们考虑下面两种情况,如图1-3所示。</p><ul><li><strong>channel 复用</strong>,即左侧小人在一个 channel 上连续发送多条消息。在同步阻塞模式下,一轮(即请求+响应)只能投递一个请求(苹果1),而后续请求(苹果2-4)都只能排队等待,右侧小人需要等待很多轮才能收到所期望的全部消息。此外,左侧小人在等待接收某个 response 期间,没有机会处理收到的其他消息,造成了数据处理的延迟。不得不感慨,左侧小人太懒惰了!</li><li><strong>线程复用</strong>,即一个线程(小人)向多个 channel 发送消息(苹果1-3,分别发向不同 channel)。左侧小人同一时刻只能做一件事,要么在工作,要么在休息;他投递了苹果1后就躺下休息,等待响应,全然不顾右侧小人2、3还在等待他们想要的苹果2、3。</li></ul><p><img src="/img/remote/1460000040347145" alt="1-3a.png" title="1-3a.png"><br><em>图1-3a channel复用</em></p><p><img src="/img/remote/1460000040347146" alt="1-3b.png" title="1-3b.png"><br><em>图1-3b 线程复用</em></p><p>在这一章里我们用漫画的形式,初步体验了同步阻塞模式与异步非阻塞模式,并分析了两种模式的区别。接下来我们从Java线程入手,对两种模式进行更加正式、更加贴近实际的分析。</p><h2>2.异步非阻塞模型</h2><h3>2.1 Java 线程状态</h3><p>在 Java 程序中,线程是调度执行的单元。线程可以获得 CPU 使用权来执行代码,从而完成有意义的工作。工作进行期间,有时会因为等待获取锁、等待网络 IO 等原因而暂停,通称“同步”或“阻塞”;如果多项工作能够同时进行,之间不存在约束、不需要互相等待,这种情况就称为“异步”或“非阻塞”。<br>受限于内存、系统线程数、上下文切换开销,Java 程序并不能无限创建线程;因此,我们只能创建有限个线程,并尽量提高线程的利用率,即增加其工作时长、降低阻塞时长。异步非阻塞模型是减少阻塞、提高线程利用率的有效手段。当然,这种模型并不能消除所有的阻塞。我们首先来看看 Java 线程有哪些状态,其中哪些阻塞是必要的,哪些阻塞可以避免。</p><p>Java 线程状态包括:</p><ul><li><strong>RUNNABLE</strong>:线程在执行有意义的工作<br>如图2-1a,线程如果在执行纯内存运算,那么处于RUNNABLE状态<br>根据是否获得cpu使用权,又分为两个子状态:READY、RUNNING</li><li><strong>BLOCKED/WAITING/TIMED_WAITING</strong>:线程正在阻塞<br>如图2-1b、2-1c、2-1d,根据阻塞原因,线程处于下列状态之一<br>BLOCKED:synchronized等待获取锁<br>WAITING/TIMED_WAITING:Lock等待获取锁。两种状态的区别为是否设置超时时长</li></ul><p><img src="/img/remote/1460000040347147" alt="图2-1.png" title="图2-1.png"><br><em>图2-1 Java 线程状态</em></p><p>此外,如果 Java 线程正在进行网络 IO,则线程状态为 RUNNABLE,但是实际上也发生了阻塞。以 socket 编程为例,如图2-2所示,在收到数据之前InputStream.read() 会阻塞,此时线程状态为RUNNABLE。</p><p><img src="/img/remote/1460000040347148" alt="图2-2.png" title="图2-2.png"><br><em>图2-2 网络IO</em></p><p>综上,Java 线程状态包括:RUNNABLE、BLOCKED、WAITING、TIMED_WAITING。其中,RUNNABLE 状态又分为内存计算(非阻塞)、网络IO(阻塞)两种情况,而其余状态都是阻塞的。<br>根据阻塞原因,本文将 Java 线程状态归纳为以下3类:RUNNABLE、IO、BLOCKED</p><blockquote><ol><li><strong>RUNNABLE</strong>:Java 线程状态为 RUNNABLE,并且在执行有用的内存计算,无阻塞</li><li><strong>IO</strong>:Java线程状态为RUNNABLE,但是正在进行网络IO,发生阻塞</li><li><strong>BLOCKED</strong>:Java线程状态为BLOCKED/WAITING/TIMED_WAITING,在并发工具的控制下,线程等待获取某一种锁,发生阻塞</li></ol></blockquote><p>要提高线程利用率,就要增加线程处于 RUNNABLE 状态的时长,降低处于 IO 和BLOCKED状态的时长。BLOCKED 状态一般是不可避免的,因为线程间需要通信,需要对临界区进行并发控制;但是,如果采用适当的线程模型,那么 IO 状态的时长就可以得到降低,而这就是异步非阻塞模型。</p><h3>2.2 线程模型:阻塞 vs 非阻塞</h3><p>异步非阻塞模型能够降低 IO 阻塞时长,提高线程利用率。下面以数据库访问为例,分析同步和异步 API 的线程模型。如图3所示,过程中涉及3个函数:</p><ol><li>writeSync()或writeAsync():数据库访问,发送请求</li><li>process(result):处理服务器响应(以 result 表示)</li><li>doOtherThings():任意其他操作,逻辑上不依赖服务器响应</li></ol><p><strong>同步 API</strong> 如图3-a 所示:调用者首先发送请求,然后在网络连接上等待来自服务器的响应数据。API 会一直阻塞,直至收到响应才返回;期间调用者线程无法执行其他操作,即使该操作并不依赖服务器响应。实际的<strong>执行顺序</strong>为:</p><ol><li>writeSync()</li><li>process(result)</li><li>doOtherThings() // 直至收到结果,当前线程才能执行其他操作</li></ol><p><strong>异步 API</strong> 如图2-3b所示:调用者发送请求并注册回调,然后 API 立刻返回,接下来调用者可以执行任意操作。稍后底层网络连接收到响应数据,触发调用者所注册的回调。实际的<strong>执行顺序</strong>为:</p><ol><li>writeAsync()</li><li>doOtherThings() // 已经可以执行其他操作,并不需要等待响应</li><li>process(result)</li></ol><p><img src="/img/remote/1460000040347149" alt="图2-3.png" title="图2-3.png"><br><em>图2-3 同步API & 异步API</em></p><p>在上述过程中,函数 doOtherThings() 并不依赖服务器响应,原则上可以和数据库访问同时执行。然而对于同步 API,调用者被迫等待服务器响应,然后才可以执行 doOtherThings();即数据库访问期间线程阻塞于 IO 状态,无法执行其他有用的操作,利用率十分低下。而异步 API 就没有这个限制,显得更加紧凑、高效。</p><p>在 IO 密集型系统中,适当使用异步非阻塞模型,可以提升数据库访问吞吐量。考虑这样一个场景:需要执行多条数据库访问请求,且请求之间互相独立,无依赖关系。使用同步 API 和异步 API,线程状态随时间变化的过程如图2-4所示。<br>线程交替处于 RUNNABLE 和 IO 状态。在 RUNNABLE 状态下,线程执行内存计算,如提交请求、处理响应。在 IO 状态下,线程在网络连接上等待响应数据。在实际系统中,内存计算的速度非常快,RUNNABLE 状态的时长基本可忽略;而网络传输的耗时会相对更长(几十到几百毫秒),IO 状态的时长更加可观。</p><p>a.<strong>同步 API</strong>:调用者线程一次只能提交一个请求;直到请求返回后,才能再提交下一个请求。线程利用率很低,大部分时间消耗在 IO 状态上。</p><p>b.<strong>异步 API</strong>:调用者线程可以连续提交多个请求,而之前提交的请求都还没有收到响应。调用者线程会注册一些回调,这些回调存储在内存中;稍后网络连接上收到响应数据,某个接收线程被通知处理响应数据,从内存中取出所注册的回调,并触发回调。这种模型下,请求可以连续地提交、连续的响应,从而节约 IO 状态的耗时。</p><p><img src="/img/remote/1460000040347150" alt="图2-4.png" title="图2-4.png"><br><em>图2-4 线程时间线:数据库访问</em></p><p>异步非阻塞模式在IO密集型系统中应用非常广泛。常用的中间件,如 http 请求[D]、redis[E]、mongo DB[F]、elasticsearch[G]、influx DB[H],都支持异步 API。各位读者可以在参考文献中,查阅这些异步 API的样例代码。关于中间件的异步 API,下面有几个注意事项:</p><ol><li>redis 的常见客户端有 jedis 和 lettuce [E]。其中 lettuce 提供了异步 API,而 jedis 只能提供同步 API;二者对比参见文章[I]。</li><li>kafka producer[J]的send()方法也支持异步API,但是该API实际上不是纯异步的[K]:当底层缓存满,或者无法获取服务器(broker)信息时,send()方法会发生阻塞。个人认为这是一个非常严重的设计缺陷。kafka 常用于低延迟日志采集场景,系统会将日志通过网络写入到 kafka 服务器,以减少线程内的阻塞,提升线程吞吐量;稍后其他进程会从 kafka 消费所写入的日志,进行持久存储。设想一个实时通信系统,单条线程每秒需要处理几万到几十万条消息,响应时间一般为几毫秒到几十毫秒。系统在处理期间需要经常调用 send() 来上报日志,如果每次调用都发生哪怕1秒的延迟(实际有可能达几十秒),延迟积累起来也会严重劣化吞吐量和延迟。</li></ol><p>最后,异步 API 有多种实现,包括线程池、select(如netty 4.x[L])、epoll 等。其共同点是调用者不需要在某一条网络连接上阻塞,以等待接收数据;相反,API 底层常驻有限数目的线程,当收到数据后,某一线程得到通知并触发回调。这种模型也称为“响应式”模型,非常贴切。限于篇幅原因,本文主要关注<strong>异步 API 设计</strong>,而不深入讲解异步 API 的实现原理。</p><h2>3.Promise设计模式</h2><h3>3.1 API形式:同步、异步 listener、异步 Promise</h3><p>上一章介绍了异步非阻塞模式和异步 API 的函数形式。异步 API 具有以下特征:</p><blockquote><ol><li>在提交请求时注册回调;</li><li>提交请求后,函数立刻返回,不需要等待收到响应;</li><li>收到响应后,触发所注册的回调;根据底层实现,可以利用有限数目的线程来接收响应数据,并在这些线程中执行回调。</li></ol></blockquote><p>在保留异步特性的基础上,异步 API 的形式可以进一步优化。上一章图2-3b展示了异步 API 的 listener 版本,特点是在提交请求时必须注册恰好一个回调;因而在下列场景下,listener API 会难以满足功能需求,需要调用者做进一步处理:</p><ol><li>多个对象都关注响应数据,即需要注册多个回调;但是 listener 只支持注册一个回调。</li><li>需要将异步调用转为同步调用。例如某些框架(如spring)需要同步返回,或者我们希望主线程阻塞直至操作完成,然后主线程结束、进程退出;但是 listener 只支持纯异步,调用者需要重复编写异步转同步的代码。</li></ol><p>为了应对上述场景,我们可以使用 Promise 设计模式来重构异步 API,以支持多个回调和同步调用。下面对同步 API、异步listener API、异步Promise API 的函数形式进行对比,如图3-1所示:</p><ul><li><strong>a.同步</strong>:调用 writeSync() 方法并阻塞;收到响应后函数停止阻塞,并返回响应数据</li><li><strong>b.异步listener</strong>:调用 writeAsync() 方法并注册 listener,函数立刻返回;收到响应后,在其他线程触发所注册的 listener;</li><li><strong>c.异步Promise</strong>:调用 writeAsync(),但不需要在函数中注册 listener,函数立刻返回 Promise 对象。调用者可以调用异步的 Promise.await(listener),注册任意数目的 listener,收到响应后会按顺序触发;此外,也可以调用同步的 Promise.await(),阻塞直至收到响应。</li></ul><p><img src="/img/remote/1460000040347151" alt="图3-1.png" title="图3-1.png"><br><em>图3-1 API形式:同步、异步listener、异步Promise</em></p><p>综上,Promise API 在保持异步特性的前提下,提供了更高的灵活性。调用者可以自由选择函数是否阻塞,以及注册任意数目的回调。</p><h3>3.2 Promise的特性与实现</h3><p>上一节介绍了 Promise API 的使用样例,其核心是一个 Promise 对象,支持注册 listener,以及同步获取响应 result;而本节将对 Promise 的功能进行更加详细的定义。注意,本节并不限定 Promise 的某一具体实现(例:jdk CompletableFuture、netty DefaultPromise),只展示共有的、必须具备的特性;缺少这些特性,Promise 将无法完成异步传递响应数据的工作。</p><h4>3.2.1 功能特性</h4><ul><li><strong>Promise的基本方法</strong></li></ul><p>Promise的基本功能是传递响应数据,需要支持下列方法,如表3-1所示:</p><p><img src="/img/remote/1460000040347152" alt="表格.jpg" title="表格.jpg"></p><p>下面以上一小节的数据库访问 API 为例,演示 Promise 的工作流程,如图3-2所示:</p><ul><li>a.调用者调用 writeAsync() API,提交数据库访问请求并获取 Promise 对象;然后调用 Promise.await(listener),注册对响应数据的 listener。Promise 对象也可以传递给程序中其他地方,使得关心响应数据的其他代码,各自注册更多listener。</li><li>b.writeAsync()内部,创建 Promise 对象并和这次请求关联起来,假设以requestId 标识。</li><li>c.writeAsync()底层常驻有限数目的线程,用于发送请求和接收响应。以 netty为例,当从网络上收到响应据后,其中一个线程得到通知,执行 channelRead() 函数进行处理;函数取出响应数据和对应的 Promise 对象,并调用Promise.signalAll() 进行通知。注意这里是伪代码,和 netty 中回调函数的实际签名略有区别。</li></ul><p><img src="/img/remote/1460000040347153" alt="图3-2a.png" title="图3-2a.png"><br><em>图3-2a 提交数据库访问请求</em></p><p><img src="/img/remote/1460000040347154" alt="图3-2b.png" title="图3-2b.png"><br><em>图3-2b 创建 Promise 对象</em></p><p><img src="/img/remote/1460000040347155" alt="图3-2c.png" title="图3-2c.png"><br><em>图3-2c 通知 Promise 对象</em></p><p><strong>- Promise 的时序</strong></p><p>Promise 的方法需要保证以下时序。此处以“A对B可见”来描述时序,即:如果先执行操作A(注册 listener )就会产生某种永久效应(永久记录这个 listener ),之后再执行操作B(通知 result )就必须考虑到这种效应,执行相应的处理(触发之前记录的listener)。</p><blockquote><ol><li>await(listener) 对 signalAll(result) 可见:注册若干 listener 后,通知result 时必须触发每一个 listener,不允许遗漏。</li><li>signalAll(result) 对 await(listener) 可见:通知 result 后,再注册listener 就会立刻触发。</li><li>首次 signalAll(result) 对后续 signalAll(result) 可见。首次通知 result后,result 即唯一确定,永不改变。之后再通知 result 就会忽略,不产生任何副作用。请求超时是该特性一种典型应用:在提交请求的同时创建一个定时任务;如果能在超时时长内正确收到响应数据,则通知 Promise 正常结束;否则定时任务超时,通知Promise 异常结束。不论上述事件哪个先发生,都保证只采纳首次通知,使得请求结果唯一确定。</li></ol></blockquote><p>此外,某次 await(listener) 最好对后续 await(listener) 可见,以保证listener 严格按照注册顺序来触发。</p><p><strong>- Promise 的非线程安全实现</strong></p><p>如不考虑线程安全,那么下列代码清单可以实现 Promise 的基本特性;线程安全的实现见下一小节。代码清单依次展示了 await(listener): void、signalAll(result)、await(): result 的实现。这里有几个<strong>注意事项</strong>:</p><blockquote><ol><li><strong>字段 listeners 存储 await(listener) 所注册的 listener</strong>。字段类型为LinkedList,以存储任意数目的 listener,同时维护 listener 的触发顺序。</li><li><strong>字段 isSignaled 记录是否通知过 result</strong>。如果 isSignaled=true,则后续调用 await(listener) 时立刻触发 listener ,且后续调用 signalAll(result) 时直接忽略。此外,我们以 isSignaled=true 而不是 result=null 来判断是否通知过 result,因为某些情况下 null 本身也可以作为响应数据。例如,我们以Promise<Exception>表示数据库写入的结果,通知 null 表示写入成功,通知Exception 对象(或某一子类)表示失败原因。</li><li><strong>signalAll(T result)在末尾处调用 listeners.clear() 以释放内存</strong>,因为listeners 已经触发过,不再需要在内存中存储。</li></ol></blockquote><pre><code class="js">public class Promise<T> {
private boolean isSignaled = false;
private T result;
private final List<Consumer<T>> listeners = new LinkedList<>();
public void await(Consumer<T> listener) {
if (isSignaled) {
listener.accept(result);
return;
}
listeners.add(listener);
}
public void signalAll(T result) {
if (isSignaled) {
return;
}
this.result = result;
isSignaled = true;
for (Consumer<T> listener : listeners) {
listener.accept(result);
}
listeners.clear();
}
public T await() {
// 适当阻塞,直至signalAll()被调用;实际实现见3.3节
return result;
}
}</code></pre><h4>3.2.2 线程安全特性</h4><p>上一章3.2.1节讲解了 Promise 的功能,并提供了非线程安全的实现。本节展示如何使用并发工具,实现线程安全的 Promise,如下所示。有下列几个注意事项:</p><ol><li>线程安全。各个字段均被多个线程访问,因此都属于临界区,需要使用适当的线程安全工具进行上锁,如 synchronized、Lock。一种最简单的实现,是将全部代码纳入临界区内,进入方法时上锁,离开方法时放锁。注意在使用 return 进行提前返回时,不要忘记放锁。</li><li>在临界区外触发 listener ,以减少在临界区内停留的时长,并减少潜在的死锁风险。</li><li>同步 await()。可以使用任何一种同步等待的工具来实现,如 CountDownLatch、Condition 。此处使用 Condition 实现,注意根据 java 语法,操作 Condition 时必须先获取 Condition 所关联的锁。</li></ol><pre><code class="js">public class Promise<T> {
private final ReentrantLock lock = new ReentrantLock();
private final Condition resultCondition = lock.newCondition();
private boolean isSignaled = false;
private T result;
private final List<Consumer<T>> listeners = new LinkedList<>();
public void await(Consumer<T> listener) {
lock.lock();
if (isSignaled) {
lock.unlock(); // 不要忘记放锁
listener.accept(result); // 在临界区外触发listener
return;
}
listeners.add(listener);
lock.unlock();
}
public void signalAll(T result) {
lock.lock();
if (isSignaled) {
lock.unlock(); // 不要忘记放锁
return;
}
this.result = result;
isSignaled = true;
// this.listeners的副本
List<Consumer<T>> listeners = new ArrayList<>(this.listeners);
this.listeners.clear();
lock.unlock();
for (Consumer<T> listener : listeners) {
listener.accept(result); // 在临界区外触发listener
}
/* 操作Condition时须上锁*/
lock.lock();
resultCondition.signalAll();
lock.unlock();
}
public T await() {
lock.lock();
if (isSignaled) {
lock.unlock(); // 不要忘记放锁
return result;
}
while (!isSignaled) {
resultCondition.awaitUninterruptibly();
}
lock.unlock();
return result;
}
}</code></pre><p>上述实现仅做演示使用,仍有较大的改进空间。生产环境的实现原理,读者可以参考jdk CompletableFutre、netty DefaultPromise。可以改进的地方包括:</p><ol><li><strong>使用 CAS 设置响应数据</strong>。字段 isSignaled、result 可以合并为一个数据对象,然后使用 CAS 进行设值,从而进一步降低阻塞时长。</li><li><strong>触发 listener 的时序</strong>。在上述代码中,Promise.signalAll() 会依次触发listener;在此期间,如果其他线程调用了异步 await(listener),由于 Promise 的响应数据已通知,该线程也会触发 listener 。上述过程中,两个线程同时触发listener ,因此没有严格保证触发顺序。作为改进,类似于 netty DefaultPromise,Promise.signalAll() 内部可以设置一个循环,不断触发 listener 直至 listeners 排空,以防期间注册了新的 listener;在此期间,新注册的 listener 可以直接加入到 listeners 中,而不是立刻触发。</li><li><strong>listener 的移除</strong>。在通知响应数据之前,Promise 长期持有 listener 的引用,导致 listener 对象无法被 gc。可以添加 remove(listener) 方法,或者允许仅持有 listener 的弱引用。</li></ol><h4>3.2.3 须避免的特性</h4><p>前面的小节展示了 Promise 的特性与实现原理。纯正的 Promise 是异步传递响应数据的工具,其应当只实现必要的数据传递特性,而不应当夹杂请求提交、数据处理等逻辑。接下来我们来看一看,Promise 在实现时应避免哪些特性,以防限制调用者所能做出的决策。</p><p>1.异步 await() 发生阻塞;该规则不仅适用于 Promise,也适用于任何异步 API 。异步API常用于实时通信等延时敏感的场景,作用是减少线程阻塞,避免推迟后续其他操作。一旦发生阻塞,系统的响应速度和吞吐量就会受到严重冲击。</p><p>以连续提交数据库请求为例。如图3-3a 所示,调用者调用了一个异步 API,连续提交3次写入请求,并在所返回的 Promise 上注册回调。</p><p>我们考察 writeAsync()与await() 如发生阻塞阻塞,将会对调用者造成什么影响,如图3-3b 所示。提交请求是纯内存操作,线程处于 RUNNABLE 状态;writeAsync() 或 await() 如果发生阻塞,则线程处于 BLOCKED 状态,暂停工作而无法执行后续操作。当发生阻塞时,调用者每提交一个请求就不得不等待一段时间,从而降低了提交请求的频率,进而推迟了服务器对这些请求的响应,使得系统的吞吐量降低、延迟上升。特别地,如果系统采用了多路复用机制,即一个线程可以处理多个网络连接或多个请求,那么线程阻塞将会严重拖慢后续请求的处理,造成比较难排查的故障。</p><p>常见的<strong>阻塞原因</strong>包括:</p><ul><li>Thread.sleep()</li><li>向队列提交任务,调用了BlockingQueue.put()和take();应改为非阻塞的offer()和poll()</li><li>向线程池提交任务,ExecutorService.submit(),如果线程池拒绝策略为CallerRunsPolicy,而任务本身又是耗时的。</li><li>调用了阻塞的函数,包括:InputStream.read()、同步的 Promise.await()、KafkaProducer.send()。注意 KafkaProducer.send() 虽然形式上是异步 API,但是在底层缓存满或者无法获取服务器(broker)信息时,send()方法仍会发生阻塞。</li></ul><p><img src="/img/remote/1460000040347156" alt="图2-1a.png" title="图2-1a.png"><br><em>图3-3a 连续提交请求</em></p><p><img src="/img/remote/1460000040347157" alt="图3-3b.png" title="图3-3b.png"><br><em>图3-3b 请求处理时间线</em></p><p>2.绑定线程池(ExecutorService),用于执行请求。如图3-4所示,线程池是异步API的一种可选模型,但并不是唯一实现。</p><blockquote><ul><li><strong>线程池模型</strong>。为了不阻塞调用者,API 内置了线程池来提交请求、处理响应;调用者可以向线程池连续提交多个请求,但是不需要等待响应。调用者提交一条请求后,线程池中的某条线程就会被独占,等待接收响应并进行处理,但在此之前无法再处理其他请求;完成处理后,该条线程重新变为空闲,可以继续处理后续请求。</li><li><strong>响应式模型</strong>。类似地,API 内置了发送和接收线程来提交请求、处理响应,调用者也不需要同步等待。调用者提交一条请求后,发送线程向网络发送请求;完成发送后,线程立刻变为空闲,可以发送后续请求。当收到响应数据时,接收线程得到通知以处理响应;完成处理后,线程立刻变为空闲,可以处理后续响应数据。上述过程中,任何一条线程都不会被某一请求独占,即线程随时都可以处理请求,而不需要等待之前的请求被响应。</li></ul></blockquote><p>综上,如果绑定了线程池,Promise 就实现了对其他模型(如响应式模型)的兼容性。</p><p><img src="/img/remote/1460000040347158" alt="图3-4.png" title="图3-4.png"></p><p><em>图3-4 线程时间线:线程池 vs select</em></p><p>3.在构造方法创建 Promise 对象时,定义如何提交请求。这种方式只能定义如何处理单条请求,而无法实现请求的批量处理。</p><p>以数据库访问为例,现代数据库一般支持批量读写,以略微提升单次访问的延迟为代价,换来吞吐量显著提升;如果吞吐量得到提升,那么平均延迟反而会下降。下面的代码片段展示了一个批量请求 API:数据对象 BulkRequest 可以携带多条普通请求 Request,从而实现批量提交。</p><pre><code class="js">/* 提交单条请求*/
client.submit(new Request(1));
client.submit(new Request(2));
client.submit(new Request(3));
/* 提交批量请求*/
client.submit(new BulkRequest(
new Request(1),
new Request(2),
new Request(3)
));</code></pre><p>为了充分利用“批量请求”的特性,调用者需要进行跨越多条请求的“宏观调控”。请求产生后可以先缓存起来;等待一段时间后,取出所缓存的多条请求,组装一个批量请求来一并提交。因此,如下面的代码片段所示,在构造 Promise 时指定如何提交单条请求是没有意义的,这部分代码(client.submit(new Request(...)))并不会被执行;而实际希望执行的代码,其实是提交批量请求(client.submit(new BulkRequest(...)))。</p><pre><code class="js">/* Promise:提交单条请求*/
new Promise<>(() -> client.submit(new Request(1)));
new Promise<>(() -> client.submit(new Request(2)));
new Promise<>(() -> client.submit(new Request(3)));</code></pre><p>4.在构造方法创建 Promise 对象时,定义如何处理响应数据,而不允许后续对响应数据注册回调。如下面的代码片段所示,在构造 Promise 对象时,注册了对响应数据的处理 process(result);但是除此以外,其他代码也有可能关心响应数据,需要注册回调 process1(result)、process2(result)。如果 Promise 只能在构造时注册唯一回调,那么其他关注者就无法注册所需回调函数,即 Promise API 退化回listener API。</p><pre><code class="js">/* 定义如何处理响应数据*/
Promise<String> promise = new Promise<>(result -> process(result));
/* 其他代码也关心响应数据*/
promise.await(result -> process1(result));
promise.await(result -> process2(result));</code></pre><p>综上,Promise 应该是一个纯粹的数据对象,其职责是存储回调函数、存储响应数据;同时做好时序控制,保证触发回调函数无遗漏、保证触发顺序。除此以外,Promise 不应该和任何实现策略相耦合,不应该杂糅提交请求、处理响应的逻辑。</p><h2>4.总结</h2><p>本文讲解了异步非阻塞设计模式,并对同步 API、异步 listener API、异步Promise API进行了对比。相比于其他两种API,Promise API 具有无可比拟的灵活性,调用者可以自由决定同步返回还是异步返回,并允许对响应数据注册多个回调函数。最后,本文讲解了Promise基本功能的实现,并初步实现了线程安全特性。</p><p>本系列共2篇文章,本文为第1篇《原理篇》。在下一篇《应用篇》中,我们将看到Promise 设计模式丰富的应用场景,将其和现有工具进行结合或对比,以及对Promise API 进行进一步变形和封装,提供异常处理、调度策略等特性。</p><h2>参考文献</h2><blockquote><strong>[A] 异步非阻塞IO</strong><br><a href="https://link.segmentfault.com/?enc=jcM7ZYg97fZsLPAYChc3xA%3D%3D.cP4h%2FAV9L0UiLjSyZAM51rAMFanLo0DOAdbfsUSSwrK9Js7jI6TU3d7MQTelgoHY" rel="nofollow">https://en.wikipedia.org/wiki...</a></blockquote><p><strong>[B] Promise</strong><br><a href="https://link.segmentfault.com/?enc=RKlFdkxB9LnvViki6MBNcw%3D%3D.buRs32NBTwiLaay1M7qnPvDBpLLIl%2FheZHlYhPNLVIV3GRNQPmQtpXnLZQwo2Jd%2FGhQQCFyLXl1p7GisG4UpdA%3D%3D" rel="nofollow">https://en.wikipedia.org/wiki...</a></p><p><strong>[C] java线程状态</strong><br><a href="https://segmentfault.com/a/1190000038392244">https://segmentfault.com/a/11...</a></p><p><strong>[D] http异步API样例:apache HttpAsyncClient</strong><br><a href="https://link.segmentfault.com/?enc=pa%2B51zduR4wLiLJbIyRsWA%3D%3D.Bp0n0l9ILsvMc9hugNoj5cmogBoeqlixJuTQeebrkDGi%2B0GHG2EEiN%2Bhu%2BFLuWZaXTsprOx9bmXQR98XBJ%2BL3gKJM2pCVColbFyFs%2Bg0jQw%3D" rel="nofollow">https://hc.apache.org/httpcom...</a></p><p><strong>[E] redis异步API样例:lettuce</strong><br><a href="https://link.segmentfault.com/?enc=zJoG2%2B%2Bh4muQzuDo7jLmeA%3D%3D.IagjEXPbvkH5uOpwC4baZay27ShoVzSZcdhJaa5RNu5ty%2BL2LwviL5b8sQe%2FA%2FtO26RLLDtsQdaSZ%2FwmhvyYd7HBe4%2FZmRot6x75yTvH%2Fz4%3D" rel="nofollow">https://github.com/lettuce-io...</a></p><p><strong>[F] mongo DB异步API样例:AsyncMongoClient</strong><br><a href="https://link.segmentfault.com/?enc=g2zz5zGsHq%2BxO7s%2F8%2Fxhbw%3D%3D.tiy6PZAMOFEcTkwD6W26YJGf6KJ3gRtFMGRHoqz%2BBHjN7EQS%2FHJRcjH1Objh3hxpE09bXOb0eITucYjZF4N6thJH6Rm2MeZmSnAATZJFvi9vf7yN48whnq%2FQ0KleQW7w" rel="nofollow">https://mongodb.github.io/mon...</a></p><p><strong>[G] elasticsearch异步API样例:RestHighLevelClient</strong><br><a href="https://link.segmentfault.com/?enc=Bo17TK6Yy4ywQQRAfn5wrw%3D%3D.yuZmuzNOjbbXWtoWibEUzs5TGkPI%2FreSax4Sj%2BghQpxDRjlXjFXJkuhAWOLkBwYTaBb1XJ54K5xeEYj2o948a%2BzCz2bjscvf4r%2FppCA6Ug3TRF%2BhMb5oB4TPTcX2z3RfxidtuebOAxobICrSEgerEg%3D%3D" rel="nofollow">https://www.elastic.co/guide/...</a></p><p><strong>[H] influx DB异步API样例:influxdb-java</strong><br><a href="https://link.segmentfault.com/?enc=I0IPBicLdvaCUJnp878p6g%3D%3D.IlwhF9tyWO2EMPUbjN%2BQJWcSy1bfieVlPRhNOIoCys2r8KD4XzhRegledvChszDgSfrqyLCaK6z8AlrAAlvdGXyPhnZUsMUj0WzwwogTSpw%3D" rel="nofollow">https://github.com/influxdata...</a></p><p><strong>[I] jedis vs lettuce</strong><br><a href="https://link.segmentfault.com/?enc=MpAXjK2rHnehIfHA0gUF3g%3D%3D.F04iPg16ZSXjMMQeRzwk%2Bf1CGkFnQKN8qW2HuBIs42wCHlnAP7eW6a%2FjkaL8SKQuxGPVvy3xTTDFXZkz6IpdeA%3D%3D" rel="nofollow">https://redislabs.com/blog/je...</a></p><p><strong>[J] kafka</strong><br><a href="https://link.segmentfault.com/?enc=beh%2FIFZtyWBiZd3%2BGT3ViQ%3D%3D.4w%2BL1hFV821hGddQtI4TuaeR3k2mntNmOP%2BYx1G60BLqIrEzjWEwTX%2Bn3UPlCVRCNHJ2KU%2BvA2IocKYKWLEVSrKPX2E0IuIF2EA827jD7JQ%3D" rel="nofollow">http://cloudurable.com/blog/k...</a></p><p><strong>[K] KafkaProducer.send()阻塞</strong><br><a href="https://link.segmentfault.com/?enc=LUAwn17qOAZwS7fJYEHX6Q%3D%3D.fHhJ9Pxu977cSXWCwd62R%2B%2FiJQQQrxIiDzy2CM8ZAK9Tb3zxlFTU6B45aen3NDtEPG8tUObVU6Q%2FkNGfjKSYXTscivtZ1CkCUhZxt8Rpb9p9v64GutSjOqp0SrLkLUbU" rel="nofollow">https://stackoverflow.com/que...</a></p><p><strong>[L] netty</strong><br><a href="https://link.segmentfault.com/?enc=U8eVsh%2BtkvsKkqm%2FFFal2w%3D%3D.Q4hJprc1qiDGQkCpWnS1jUpUdfClUvh7pg1WVJ2RNHd0aviku6NsXyz80P6u09Fr" rel="nofollow">https://netty.io/wiki/user-gu...</a></p>
有道互动内容引擎 Ceramics 的业务实践
https://segmentfault.com/a/1190000040309019
2021-07-07T18:27:06+08:00
2021-07-07T18:27:06+08:00
有道AI情报局
https://segmentfault.com/u/youdaotec
0
<h2>前言</h2><p>Ceramics 是一款 HTML5 互动内容引擎,主要为有道互动教学场景提供 Web 互动题目开发解决方案帮助开发者快速生产高质量的题目内容。</p><p>本文将从业务场景出发,介绍 Ceramics 的技术实现,并讲述其如何高效地为高质量的互动内容生产赋能,希望能给对 Web 互动技术感兴趣的前端开发人员提供一些参考。</p><h2>一、背景</h2><h3>1. 互动内容</h3><p>互动内容是有道在线教学中的重要组成部分,应用场景广泛例如:</p><ul><li>在<strong>直播课堂</strong>中,通过教师端控制向学生端推送互动内容,学生在观看直播或课后回放时,均可收到互动内容,作答后可提交答案至教师端;</li><li>在<strong> AI 教学视频</strong>中,穿插互动内容,根据用户的作答结果播放不同的后续视频片段;</li><li>在<strong> App 、小程序</strong>中,课后通过复习闯关、自由练习、错题本等形式让用户进行知识点巩固,辅导老师在后台可查看用户答题情况进行专项指导;</li><li>在<strong>对外合作、市场活动、用户学前测试</strong>等页面中穿插互动题目来进行拉新引流。<br>互动题目通过趣味性的交互过程、及时合理的反馈来激发学习兴趣,吸引注意力,让用户在互动式、探索式的趣味课堂中更高效地获取知识,巩固学习。</li></ul><p><img src="/img/remote/1460000040309021" alt="图1.png" title="图1.png"><br><em>图1:互动内容的应用场景</em></p><h3>2. 题目模板</h3><p>一道互动题目由题目模板和题目数据组成:研发人员根据教研需求开发出包含交互逻辑、动画效果、正误判断的题目模板,教研根据题目模板的数据结构配置题目数据。</p><p>目前团队已开发近300个题目模板(下文简称题板),涵盖<strong>填空、选择、拖拽、绘图、连线</strong>等题型。通过良好的接口设计和数据结构定义,一个题目模板可以适用于多种教学模式,题目模板之间也可以互相组合,形成交互更丰富的题目模板。</p><p><img src="/img/bVcTilD" alt="" title=""><br><em>图2:互动题目、题目模板、互动引擎之间的关系</em></p><p>以图3的两类点选消除题目为例,<strong>判题逻辑、场景风格、动画效果</strong>等有差异,但题目交互相似度较高。基于此,将两类题目用一个题目模板开发,保留较高的配置度,数据结构中定义背景图片、选项图片、动效形态、判题模式、题干文本等字段,即可通过一个题目模板支持不同玩法的多道题目。</p><p><img src="/img/remote/1460000040309022" alt="图3.png" title="图3.png"></p><p><em>图3:一个题目模板支持多道题目</em></p><h3>3. 互动引擎</h3><p>互动引擎是互动研发的基础,一款好的互动引擎能大大降低研发成本。从上文的图2可以看到,互动内容引擎提供了渲染、用户交互、动画、声音、资源管理等能力,开发人员基于其可以开发出符合业务场景的题板,题板根据不同配置数据来渲染出定制化的题目内容。</p><p>为了应对业务的高速发展,保障高效稳定地生产高质量的互动内容,少儿项目组的前端团队自研了一款高性能、好拓展、易于开发的互动内容引擎:<strong>Ceramics</strong>。Ceramics 是基于TypeScript+React 开发的 HTML5 互动引擎,包含用户交互、声音、动画、渲染、资源管理等功能,Web 开发人员利用其提供的 API 和组件,仅需较低的学习成本即可快速开发出 iOS、Android、Web、H5 多端适配的互动题板。</p><p>自 Ceramics 投入使用后,目前已陆续为少儿组的多学科多条业务线提供内容生产服务。下文将从架构设计、用户交互、动画效果、组件设计等方面展开介绍 Ceramics 的技术实现,希望能给对互动技术感兴趣的前端开发人员提供一些参考。</p><h2>二、技术与实现</h2><h3>1. 基础架构</h3><p>首先我们先来看一下 Ceramics 的整体架构。</p><p><img src="/img/remote/1460000040309023" alt="图4.png" title="图4.png"></p><p><em>图4:Ceramics 架构</em></p><p>互动引擎简单来说就是将题目数据渲染到界面的工具,由数据层、视图层和业务逻辑层构成。数据带动视图,渲染引擎根据逻辑将数据渲染到画布上。</p><p>数据以 <strong>JSON</strong> 形式存储于配置文件中,配置文件通常包含以下信息:</p><ul><li><strong>UI层配置</strong>:如页面布局,元素数量、尺寸、定位等。</li><li><strong>脚本配置</strong>:如元素出现的时机,动画播放的顺序,用户交互行为,反馈等。</li><li><strong>资源配置</strong>:如背景图、音效、动效的资源地址等。</li><li><strong>题目数据配置</strong>:题目具体的信息,如文案,题干,图片,答案,读题语音等。</li></ul><p>Web 开发人员的主要工作在视图层和业务逻辑层,即如何将配置文件可视化,包括题板的核心逻辑、具体交互的实现、以及判题的计算。我们通常在 Canvas 区域实现复杂动画效果和绘制,使用DOM实现布局,使用 TSX+hooks 编写逻辑代码。为了减少这一步的工作量,引擎本身提供了:</p><ul><li><strong>动画能力</strong>:包括 CSS 动画、JS 动画、骨骼动画、AE 动画的显示和控制,并将常用动效封装成动效库。</li><li><strong>音频播放能力</strong>:支持 mp3 音频文件的播放和语音合成 tts 文件的播放。</li><li><strong>渲染能力</strong>:基于 HTML、Canvas、WebGL 的 2D、3D 渲染。</li><li><strong>用户交互</strong>:封装了拖拽、按压、点击、输入、手写、切割、3D交互等用户交互事件。</li><li><strong>适配与兼容</strong>:经过完善测试的移动端适配和兼容,支持 iOS 9(Safari 9),Android23(Chrome 44),Web、微信小程序等平台。</li><li><strong>性能优化</strong>:通过懒加载、资源打包优化等手段对引擎进行了优化,通过 Sentry 监控、3D 动画帧率检测等保障引擎的可靠性。</li></ul><p>基于上述基础能力,我们封装了一些常用的原子组件和功能插件,如手写板、计算器、算盘等,及游戏化常用的的闯关、倒计时、引导动画等;还封装了通用题板组件,实现拖拽、选择、填空、连线等基础题型的逻辑和交互。原子组件之间可互相组合封装成功能插件,通用题板组件和功能插件可组合搭配形成业务题板组件。</p><p>上述能力和组件及详细的接入文档和开发文档,降低了互动题板的开发门槛,Web开发人员无需提前学习 2D、3D、图形学、游戏开发的知识,也可以快速上手开发满足业务需求的互动题板。</p><h3>2. 交互能力</h3><p>交互能力是用户和屏幕产生互动的基础,常见的用户交互包括拖拽、按压、点击、输入、手写、切割、3D 场景下的交互等,互动题板在 Web 端和移动端都可使用,需要同时考虑鼠标和手指触摸的交互。</p><p>Ceramics在交互能力的设计中,采用了游戏开发中流行的 ECS(entity-component-system)设计模式作为底层架构。</p><p>ECS 的一个核心理念是组合优于继承,即将不变的部分使用继承以方便复用,将多变的部分用组合来方便拓展。这样的设计模式可以保证引擎的高扩展性。</p><p>在互动内容中,每一个元素(小动物、草地、石块、气球等)都是一个实体 entity,每个实体可以绑定一个或多个组件,每个组件描述不同的能力(移动、拖拽、点击等),通过组合的方式给实体赋予一个或多个能力,也可以动态添加或删除能力。</p><p>以一个最简单的按钮为例,它本身是一个没有任何能力的实体,用一个<div/>元素表示。按钮可以有按压按钮和开关按钮,分别应用于不同场景。我们自定义了 usePress 的 hook 和 useToggle 的 hook ,统一处理 Web 端和移动端的 mouse 和 touch 交互,抹平浏览器间的差异,对外暴露简洁 API ;基于 usePress 封装了 useButton 组件,表示按钮可被按压的能力;基于 useToggle 和 useButton 封装了 useToggleButton 组件,表示开关按钮的基本能力。最后,给按钮组件绑定 useButton 或 useToggle ,成为 Button 组件和 ToggleButton 组件,即为按钮赋予了对应的能力。</p><p><img src="/img/remote/1460000040309024" alt="图5.jpg" title="图5.jpg"><br><em>图5:按钮组件交互能力设计</em></p><p>例如拖拽的交互,如图6所示,简单来说就是物体拖到指定的热区的行为,正误判断就是在指定时机对热区中的物体与答案设定中的物体进行比较。</p><p><img src="/img/remote/1460000040309025" alt="图6.png" title="图6.png"><br><em>图6:拖拽物体与热区</em></p><p>可拖拽物体、不可拖拽物体本质上都是同样没有能力的实体。将拖拽能力封装于独立的组件,处理 mouse 和 touch 时的移动行为以及拖拽物体与热区的碰撞检测。给可拖拽物体赋予了 draggable 的能力,给目标热区赋予 droppable 的能力,并通过拖拽组件暴露的接口进行后续逻辑处理,即可实现拖拽行为。</p><p>除了交互能力的实现之外,ECS 的设计思想在 Ceramics 的很多模块中都有体现,例如通过自定义的 hooks 来表示能力,可以实现更大程度的逻辑复用。Ceramics 中有很多自定义 hooks ,如 useQuestion hooks ,将做题流程整合在一起,包括校验答案、根据校验结果正误反馈、累计错误次数等;useViewport hooks ,封装移动端适配能力;useAnime hooks,封装了动画操作能力,如播放、暂停、重播等。不同题板引入相同 hooks 可以统一代码风格,提高开发效率,新的开发者也更好理解,易于上手。</p><p><img src="/img/remote/1460000040309026" alt="图7.png" title="图7.png"><br><em>图7:useQuestion hooks</em></p><h3>3. 动画效果</h3><p>动画是互动内容的一个重要组成部分。逼真趣味的动画效果不仅能辅助展示教学内容,还可以帮助提升用户沉浸感,吸引注意力,激发学习兴趣,更好地留存用户。</p><p>常见的 Web 动画方案主要有序列帧、CSS 动画、JS 动画、Canvas、WebGL 等,Ceramics 通过引入常用的动画库来支持多种动画形式,针对业务场景和兼容进行二次封装。对于一些简单动画,研发只需要简单的API调用即可实现效果。一些前端实现成本较高的复杂动效,由设计师通过 AE、Spine 等工具制作,研发引入 Ceramics 中相应的组件即可实现动画的渲染和控制。</p><h4>3.1 Lottie</h4><p>Lottie 是 Airbnb 推出的可应用与 Web, Android, iOS 等多个平台的动效解决方案。动画师用AE(After Effects)制作出动画后,通过 AE Bodymovin 插件可以导出在移动端和 Web端渲染动画的 json 文件。前端通过 Lottie 库可以对包含动画信息的 json 文件进行解析和渲染。Lottie 动画使用简单,高效高性能,效果还原能力强,跨平台支持性好,在 iOS,Android, Web,小程序端都有良好的呈现。</p><p><img src="/img/remote/1460000040309027" alt="图8.gif" title="图8.gif"><br><em>图8:lottie动画</em></p><h4>3.2 Spine</h4><p>Spine 是游戏开发中常用的2D骨骼动画制作工具,通过 Spine 制作的动画可以导出为 json + atlas + png 文件。Spine Runtime 是 Spine 官方提供的可以解析 spine 导出的动画文件的库,其中 HTML Canvas runtime 提供了 Web 端渲染 Spine 动画的能力。</p><p>Spine 动画可应用于不同的引擎和语言,因此在需要跨端协作的业务线,设计师可以用一套spine 动画适配不同应用,提升工作效率,避免重复劳动。</p><p><img src="/img/remote/1460000040309028" alt="图9.gif" title="图9.gif"><br><em>图9:spine动画</em></p><h4>3.3 基于Anime.js的动效库</h4><p>Anime.js 是一个轻量的 Javascript 动画库,可对 CSS 属性、SVG、DOM 和 JavaScript 对象进行动画。Anime.js 的内置交错系统使复杂的动画和重叠动画变得简单。</p><p>Ceramics 基于 Anime.js 开发了更符合业务需求的动效库,封装了题板通用的动效,如:</p><ol><li>强调(缩放、更换样式、抖动、闪烁)</li><li>元素入场出场动画(渐入、渐出、闪现、掉落)</li><li>元素入场出场动画(渐入、渐出、闪现、掉落)</li></ol><p>动效库也是独立发布的 <strong>npm</strong> 包,可以服务于多个工程,提高开发效率。项目中直接引入@ceramics-ae中的动效组件使用,给<strong>DOM</strong>元素添加动效名称和参数配置即可实现效果。利用<strong>timeline</strong>可以对单步动画进行串联,支持单次播放、循环播放、时间轴等功能。</p><pre><code class="js">import { Teeter } from "@ceramics-ae/core"
Teeter.play({ el }) //抖动
StyleChange.play({el,config: {style: { color: "#F5636C",background: `center/100% no-repeat url(${OptionStyle[type].bg_wrong})`}}}) //更改css样式</code></pre><h4>3.4 WEB 3D</h4><p>在 Web 端实现3D效果,当前最流行的技术就是 WebGL 。WebGL基于 OpenGL ,OpenGL 是一个跨平台的 2D、3D 绘图标准,WebGL 把 OpenGL 与 JS 结合在一起,从而在 Web 端展示 3D 场景和模型。</p><p>WebGL 功能强大,提供了大量的基础绘图 API ,但上手门槛较高,代码的复杂度高,需要开发者具有前置的图形学知识和数学知识。</p><p>Three.js 是一款 WebGL 开源引擎,对 WebGL 的底层渲染细节和复杂的数据结构进行了封装,开发人员通过简单的 API 调用即可配置灯光,顶点着色器、片元着色器,自动生成矩阵,视角控制、射线等,快速进行 3D 模型制作。我们基于 Three.js 制作了魔方、立体图形等3D效果,开发简单,效果还原度高,在移动端也能保持良好的性能和兼容。</p><p><img src="/img/remote/1460000040309029" alt="图10.png" title="图10.png"></p><p><em>图10:web 3D</em></p><p>一组高质量的动效往往是视觉、动效师、前端开发合力的产物,开发者根据不同的应用场景、素材依赖、开发成本、性能和兼容性选择不同的动画方案,可以在表达出丰富的动画效果的同时降低设计师与研发的协作成本,提高团队工作效率。</p><h3>4. 题板组件设计</h3><p>Ceramics作为一款互动内容引擎,目标之一是高效地为多业务方生产题目模板,我们希望每一个题目模板都能够服务于更多业务方,当新的业务方有需求时可以更快地接入。</p><p>基于此,我们将每个题板封装成题板组件,独立开发,独立发布为 npm 包;题板与业务解耦、题板与题板解耦,由上层业务方负责控制多个题板间的组合、切换等逻辑;题板组件也可以作为子组件被其他题板引入,进行二次封装,更大程度提高题板的复用性。</p><p><img src="/img/remote/1460000040309030" alt="图11.png" title="图11.png"><br><em>图11:题板组件与业务方的调用关系</em></p><p>通常当上层业务方使用题板时,需要收集题板内部的状态来实现外部的业务逻辑,例如:题板加载成功时去除过渡动画和背景音乐、用户答错时计入错题本、答对时切换下一题等,题板内部也需要根据外部的数据更新状态,例如:用户在不同的游戏模式中进入题目,题板应切换对应的界面和逻辑。</p><p>针对以上业务场景,题板组件定义了一套通用的接口:</p><p><img src="/img/remote/1460000040309031" alt="图12.png" title="图12.png"></p><p><em>图12:题板组件接口</em></p><p>上层业务方安装 npm 包,按需引入题板,根据接口定义传入 props 即可完成接入。</p><pre><code class="js">import { CeramicsProvider, DragCetegoryQuestion } from "@ceramics-math/core"
<CeramicsProvider>
<DragCetegoryQuestion //题目模板组件
questionConfig = {} //题目配置数据
deviceConfig = {}/> //设备配置数据
</CeramicsProvider></code></pre><p>题板组件之间相互独立,但又拥有相似的逻辑和组件依赖。为了更好地重用代码、管理项目依赖,我们引入了 Lerna 框架进行多包管理,将多个题板组件作为子项目合并到一个父项目中,重复的代码逻辑也抽取成该父项目下的另一个独立子项目。</p><p><img src="/img/remote/1460000040309032" alt="图13.png" title="图13.png"></p><p><em>图13:多包管理的结构</em></p><p>多包管理带来的<strong>收益</strong>主要有:</p><ul><li>所有子项目可以共享同一份打包、测试配置;</li><li>子项目内部可以像引入其他依赖包一样引入其他子项目,Lerna 会自动识别并导向内部项目,实时同步更新;</li><li>处理依赖包的依赖关系更加方便,通过 Lerna bootstrap 命令安装所有子项目的依赖包,在安装依赖时依赖提升,即把所有项目 npm 依赖文件都提升到根目录下,避免相同依赖包在不同项目安装多次。</li></ul><p>通过Lerna的命令可以很方便地执行所有组件的build、test和组件发布流程,更新组件中相应的依赖版本号并发布,还会在组件发布之后打 tag 以及生成 changelog。</p><p>此外,由于不同的题板在打包发布配置、接口定义、移动端适配规则、组件依赖等存在较多的相似性,为了方便新同事上手,避免接口遗漏,统一代码风格和数据结构,我们还定义了初始化模板,引入自动化构建工具 Plop 协助快速创建题板组件。通过交互式命令行输入,即可动态地在指定目录下生成题板组件,包括初始化代码和配置文件。</p><p><img src="/img/remote/1460000040309033" alt="图14.png" title="图14.png"><br><em>图14:交互式命令行</em></p><p><img src="/img/remote/1460000040309034" alt="图15.png" title="图15.png"></p><p><em>图15:初始化模板与生成的文件</em><br>如本章节所述,明确的接口定义、方便快捷的接入方式、多包管理和自动化构建的引入,使得上层业务方可以快速接入题板组件库,题板组件的开发、迭代和维护也更加容易。</p><h3>5. 原子组件和插件</h3><p>为了进一步提高开发效率,我们根据常见的业务场景,封装了许多原子组件和功能插件,开发人员通过简单的组件引入和传参即可引入。</p><p><img src="/img/remote/1460000040309035" alt="图16.png" title="图16.png"><br><em>图16:原子组件和功能插件</em></p><p>我们把界面中的最小元素理解为原子组件,如按钮、标题、展示板、选项、进度条等,根据 props 的不同配置样式和交互。</p><p><img src="/img/remote/1460000040309036" alt="17.png" title="17.png"></p><p><em>图17:原子组件</em></p><p>功能插件由多个原子组件组合而成,耦合了一些业务逻辑,常用插件如手写板、计算器、算盘等,及游戏化常用的的闯关、倒计时、引导动画等。</p><p><img src="/img/remote/1460000040309037" alt="18.png" title="18.png"><br><em>图18:功能插件</em></p><p>根据不同业务场景,功能插件可以适配不同的 UI 和动效。下图为键盘组件在不同题板中的样式:</p><p><img src="/img/remote/1460000040309038" alt="19.png" title="19.png"></p><p><em>图19:键盘组件</em></p><p>一个题板组件中会有多个原子组件和功能插件。原子组件和功能插件内部可能依赖其他原子组件,也可能会依赖一些通用的 hooks 、全局 Context 、动画、 utils 等。原子组件和功能插件也是多包工程的子项目,可以独立导出并发布为 npm 包,不仅可被任意题板组件引入,也可被其他原子组件引入,还可以被引入到其他项目中。</p><h3>通用题板</h3><p>除了上文中的原子组件和插件,Ceramics 还内置了通用题板组件来进一步提高开发效率。</p><p>通用题板为一些最基础的题目模板,如通用拖拽、通用选择、通用填空、通用连线等。通用题板封装了基本逻辑和交互,更多的业务逻辑则以数据的形式表达,这样可以提高单个题目模板的复用性,使得一个通用题板支持 N 种交互和样式相似度较高的题板需求。</p><p>通过对多个题板的归类和抽象,我们总结出来几类基础题板的基本要素:</p><p>如下图中的多个拖拽题,由画布、热区、可拖拽物体、不可拖拽物体组成。</p><p><img src="/img/remote/1460000040309039" alt="20.png" title="20.png"></p><p><em>图20:拖拽题基本元素</em></p><p>同理,<strong>选择题板</strong>的基础元素有画布、可点击物体、不可点击物体;<strong>填空</strong>有画布、物体、填空框、选项框;连线有画布、可连线物体、不可连线物体。物体的类型包括图片、动画、文字等。</p><p>除了基础元素外,题板的反馈也有相似之处:例如答对后出现正确动画,答错后出现错误动画、连线匹配成功或拖拽正确时A物体消失替换成B物体等。</p><p>与上文中提到的ECS设计模式一致,当给物体赋予了可拖拽、可点击、可连线等交互能力,并给每次交互都添加相应的反馈,即可实现一个题目的基本互动。</p><p>基于此,我们抽象出了通用题板,在题板中处理元素渲染、用户交互和提交判断逻辑,至于页面中哪些元素可点击、哪些可拖拽、每个元素的数量、样式、位置和动画,不同时机的反馈等,都可以用数据来表达,通过数据带动视图进行渲染。</p><p>我们封装了一些基础类,比较常用的有 Entity(物体),Feedback(反馈),Area(热区)、ShowCondition(出现时机)等。</p><p>页面中的所有元素都属于 Entity 类,EntityType 表示物体的类型,有图片、动画、文本、标题、提交按钮等,不同类型的物体有不同的配置项和渲染方式。</p><p>给entity添加不同的属性即可赋予其不同的交互,如clickable表示时可被点击的选项,draggable表示可被拖拽的物体,droppable表示可被放置的热区,droppable的热区还可以配置形状、容量、吸附精确度等属性。</p><pre><code class="js">export interface Entity extends Rectangle {
id: string | number // 物体的唯一id
name: string // 物体的名称
entityType?: EntityType // 物体的类型
draggable?: boolean // 可拖拽
droppable?: boolean // 可放置
clickable?: boolean // 可点击
linkable?: boolean //可连线
zIndex?: number // 层级,0-999,越大层级越高
show?: ShowCondition // 何时显示,默认always
feedbacks?: Feedback[]
[key: string]: any
}</code></pre><p>feedbacks 字段用来配置一组反馈事件,反馈主要由时机和行为定义。</p><pre><code class="js">
export interface Feedback {
type: FeedbackType | string // 反馈时机, FeedbackType和AND,OR组合而成的字符串
action: FeedbackAction // 反馈的行为
data: FeedbackData // 反馈行为需要的数据
}</code></pre><p>反馈的时机(type)有:点击、放入热区、热区已满、拖对热区、拖错热区、开始连线、连线结束、动画播放完成、整体判题正确、整体判题错误等。</p><p>反馈的行为(action)有:显示物体、隐藏物体、播放音频、替换图片、检查答案等。</p><p>同一个动作可能触发多个反馈,一个反馈也可以在不同的触发时机被触发。通过Feedbacks可以把反馈步骤串联起来,类似于动画脚本,使得一个常规的题板具有更丰富的玩法。</p><p>经过上述拆解,通用题板的设计思路也逐渐清晰了起来。以下图中的通用选择题板为例:</p><p><img src="/img/remote/1460000040309040" alt="21.png" title="21.png"><br><em>图21:通用选择题板</em></p><p>选择题的核心交互流程为:</p><blockquote><ol><li>点击可点元素,触发点击反馈;</li><li>满足提交条件,触发提交反馈,进行判题,触发正/误反馈;</li><li>满足题目结束条件,题目结束不可作答,否则可以继续作答。</li></ol></blockquote><p>提交答案的时机主要有3种:</p><blockquote><ol><li>无提交按钮时,点击可点元素立即提交;</li><li>无提交按钮时,点击元素数量与允许提交的数量相等后立即提交;</li><li>有提交按钮按钮时,点击提交按钮提交。</li></ol></blockquote><p>选择题支持单选、多选、部分选择、无正确答案的选择(如投票)。因此判断答案的条件也不同:</p><blockquote><ol><li>单选 & 多选;用户答案与题目正确答案一致;</li><li>部分选择:用户答案与题目正确答案部分一致;</li><li>无正确答案的选择:任意选项均为正确。</li></ol></blockquote><p>下面是通用选择的基本数据结构:</p><pre><code class="js">interface QuestionConfig {
canvas: Canvas //画布
answer: Answer //正确答案
entities: Entity[] //物体
submit?: SubmitEntity //提交按钮
audio?: { //音效
correct?: string
wrong?: string
click?: string
}
aeTemplate?: string //动画模板
maxCheckNum?: number //最大错误次数
allowMultipleChoice?: boolean //是否允许多选
allowPartChoice?: boolean // 是否允许部分选择
showHint?: boolean // 是否出现引导
}</code></pre><p>如上文所述,题目由题板和数据组成,通用题板中主要的业务逻辑包括:处理上述渲染和交互,定义配置数据的数据结构,根据配置数据进行相应的展示和反馈。例如,根据entities渲染物体,给clickable的物体绑定点击事件,根据entity中的feedbacks字段展示反馈;根据submit字段处理提交逻辑和提交按钮的渲染;根据answer、allowPartChoice、allowMultipleChoice等字段进行题目判断。</p><p>通过数据驱动页面的渲染,使得一个题板可以满足大部分同类型的教研需求,教研可以在配套的题板可视化配置界面中配置数据来创作新的题板,无需研发介入;研发人员也可以基于通用题板进行二次封装,通用题板可以作为组件引入到其他题板中,多个通用题板可以组合形成新的题板,大幅降低了新题板的开发成本。</p><h2>三、总结与展望</h2><p>本文主要介绍了有道互动内容引擎Ceramics在架构设计、用户交互、动画效果、组件设计等方面的技术实现,从研发视角讲述互动内容的生产过程,以及互动引擎是如何让互动内容的生产链路更高效地运转起来的。</p><p>目前 Ceramics 还在成长阶段,未来我们将持续迭代,不断丰富开发工具链。我们将打造低代码开发平台,拼搭式可视化开发题板,使得编写交互逻辑更加容易;打通设计图到代码的转换通道,开发素材库,优化 UI 和研发的合作体验;不断提高高效运行和渲染能力,向游戏化迈进,支持更复杂的游戏玩法,更炫酷的动画效果;增加实时互动,多人对战的能力,进一步提升直播课等场景的互动性与趣味性。</p><p>我们的愿景是是打造一款高性能的 Web 互动引擎,让前端工程师更低成本地开发高质量的互动内容。未来我们将不断扩大 Ceramics 的影响力,为更多业务赋能。</p>
中国大学 MOOC Android 客户端开发提效之页面信息
https://segmentfault.com/a/1190000040267656
2021-06-30T18:04:58+08:00
2021-06-30T18:04:58+08:00
有道AI情报局
https://segmentfault.com/u/youdaotec
0
<p><img src="/img/bVcS5w1" alt="" title=""></p><p>本文主要描述了怎么样提高一个客户端开发排查和定位的效率,并且动手写了一个小工具的实践和思考,以及团队中其他合作者能够提高了定位问题效率,验证功能是否准确的效率。</p><p><strong>作者/</strong>马杰 中国大学 MOOC 团队</p><p><strong>编辑/</strong>刘振宇</p><h2>一、前言</h2><p>中国大学 MOOC 是由网易与高教社携手推出的在线教育平台,承接教育部国家精品开放课程任务,向大众提供中国知名高校的 MOOC 课程。目前,无论是课程数量、质量还是社会影响力,中国大学 MOOC 都已成为全球领先的中文慕课平台。</p><p>在日常的 Android 开发中,我们经常会遇到以下的一些问题:测试、运营、产品同学跑过来说这个页面出了问题,赶紧看下。这时候客户端开发同学就需要赶紧定位到具体的某个页面。</p><p>据观察,大部分的情况下对于一个突发页面的问题定位,或者业务方想让开发者确认这个页面的业务逻辑的时候,客户端开发者,往往需要花费比较长的时间去给业务方答复。如果近期业务可能还能记得,但是客户端的页面比较多,想要快速定位到具体业务页面,那么就需要花更多的时间去找相关的页面。</p><p>所以本文的想法是怎么<strong>快速找到对应的页面</strong>,帮助开发<strong>快速的进入业务代码</strong>,快速的回复业务方提出的问题。</p><h2>二、方案实施</h2><p>在探讨方案的时候,我们需要对比目前有哪些方案,对比之后再选择一种更加有效的方法。</p><h3>2.1 解决问题的常用方式</h3><p>在 Android 开发中解决上述提供的问题,常用的有以下 3 种方式:</p><ul><li>打开 Android studio,靠着源码记忆,文案记忆去搜索;</li><li>使用 adb 命令来过滤当前的 activity;</li></ul><pre><code>// windows
adb shell dumpsys window windows | Select-String -Pattern 'mCurrentFocus|mFocusedApp|mLastOpeningApp|mObscuringWindow'
// Mac
adb shell dumpsys window windows | grep -E 'mCurrentFocus|mFocusedApp|mLastOpeningApp'</code></pre><ul><li>全局搜索关键文案。</li></ul><p>上面 3 种方式是能够解决问题,但从<strong>时间效率</strong>上分析,可以估算一下每一种方式大概需要多长时间:</p><p><strong>第一种</strong>,按照个人经验,熟悉项目代码的同学最快也要几十秒左右,慢的话 10 几分钟;</p><p><strong>第二种</strong>,使用 adb 能够几秒就定位到页面,但是需要记住命令,或者提前设置命令快捷方式;</p><p><strong>第三种</strong>,如果有很多相同文案,需要多搜索几遍,时间也可能是10几秒到1分钟不等。</p><p>所有上面几种方式得出的时间效率就是几秒到几分钟不等,而且基本都是需要代码或者 adb 的开发工具,依赖于开发环境。</p><p>既然需要花的时间也不少,那么是不是应该做一个工具来提升更快的定位速度,提升定位效率呢?</p><h3>2.2 更加高效的方法</h3><p>其实思路很简单,就是写一个开发的SDK,用来实时关注当前的页面信息。这个页面信息主要包含如下的信息:</p><ol><li>当前 Activity 是哪个?</li><li>当前的 Fragment 是哪个?</li><li>当前页面的参数传递,如:intent 中的各种参数是什么?</li></ol><p><strong>效果图如下</strong>:</p><p><img src="/img/bVcS21r" alt="" title=""></p><p>从上面信息就能够很快的定位到当前的页面;当一个页面的深度到非常深的时候,这样的小工具就特别好用;<strong>最快速度只要几秒</strong>就能快速定位到页面,效率提升<strong>快几十倍不止</strong>,而且能够当着测试和产品的面,能够把当前关键的参数给他看,如:xxxId、埋点信息等。</p><h3>2.3 主要实现原理</h3><p>上面的小工具,主要的工作是<strong>获得当前的Activity</strong>。获得当前Activity的方式主要有以下几种方式:</p><blockquote><ol><li>通过 RunningTaskInfo的 topActivity,该方法在后续的一些版本已经被禁用</li><li>手写代码管理Activity,这个方法比较粗暴,维护比较麻烦;</li><li>通过反射 ActivityThread获得 currentActivityThread 从 mActivities 中查询获得;</li><li>使用AccessibilityService 这个辅助功能,这个方法获得的信息比较少;</li><li>通过 ActivityLifecycleCallback 监听来获得。</li></ol></blockquote><p>经过对比,选择使用<strong>AccessibilityService</strong>和<strong>ActivityLifecycleCallback</strong> 这2种方式去尝试。以下就简单的说下这2种方法的实现,并进行二者之间的对比以及最后做出的选择。</p><h4>2.3.1 ActivityLifecycleCallback 方式</h4><pre><code> private static Activity topActivity;
@Override
public void onActivityResumed(Activity activity) {
topActivity = activity;
}</code></pre><p>是不是很简单?为了避免内存泄漏,可以在 onDestroy 的时候把 topActivity 设置成 null。这种方式<strong>简单快速</strong>,不需要申请权限。</p><h4>2.3.2 AccessibilityService 方式</h4><ul><li><strong>继承 AccessibilityService</strong></li></ul><pre><code>@Override
public void onAccessibilityEvent(AccessibilityEvent event) {
Log.d(TAG, "onAccessibilityEvent");
if (event.getEventType() == AccessibilityEvent.TYPE_WINDOW_STATE_CHANGED) {
if (event.getPackageName() != null && event.getClassName() != null) {
ComponentName componentName = new ComponentName(
event.getPackageName().toString(),
event.getClassName().toString()
);
ActivityInfo activityInfo = tryGetActivity(componentName);
boolean isActivity = activityInfo != null;
if (isActivity) {
Log.d(TAG, "CurentActivity " + componentName.flattenToShortString());
Log.d(TAG, "CurentActivity " + event.getPackageName().toString());
}
}
}
}
private ActivityInfo tryGetActivity(ComponentName componentName) {
try {
return getPackageManager().getActivityInfo(componentName, 0);
} catch (PackageManager.NameNotFoundException e) {
return null;
}
}</code></pre><ul><li><strong>manifest 配置</strong></li></ul><pre><code><service
android:name=".WindowChangeDetectingService"
android:permission="android.permission.BIND_ACCESSIBILITY_SERVICE">
<intent-filter>
<action android:name="android.accessibilityservice.AccessibilityService"/>
</intent-filter>
<meta-data
android:name="android.accessibilityservice"
android:resource="@xml/accessibilityservice"/>
</service></code></pre><ul><li><strong>需要 res/xm/accessibilityservice.xml 文件</strong></li></ul><pre><code><?xml version="1.0" encoding="utf-8"?>
<!-- These options MUST be specified here in order for the events to be received on first
start in Android 4.1.1 -->
<accessibility-service
xmlns:tools="http://schemas.android.com/tools"
android:accessibilityEventTypes="typeWindowStateChanged"
android:accessibilityFeedbackType="feedbackGeneric"
android:accessibilityFlags="flagIncludeNotImportantViews"
android:canRetrieveWindowContent="true"
android:description="@string/accessibility_service_description"
xmlns:android="http://schemas.android.com/apk/res/android"
tools:ignore="UnusedAttribute"/></code></pre><h4>2.3.3 2种方式的对比</h4><p>从<strong>实现角度</strong>对比,使用 ActivityLifecycleCallback 比 AccessibilityService 更加简单。但是 AccessibilityService 有个优势就是可以不用集成到自己的 app 里面,可以独立运行,可以查看所有的当前页面是属于哪个 Activity,可以跨进程使用。使用 ActivityLifecycleCallback 必须要集成到自己的 app 中。</p><p>在<strong>实践过程</strong>中,其实我们不只是想获得当前的 Activity ,我们还想知道当前的 Activity 中有哪些当前的 fragment, 当前的 Activity 从上一个 Activity 中获得了哪些参数,当前的fragment 中有哪些参数等细节信息,那么只能集成到 app 中去的时候才会比较容易获得。所以最后选择了使用 ActivityLifecycleCallback 的方式。</p><h4>2.3.4 页面更详细的信息</h4><p>一般页面上的信息开发,简单一点的就是一个 Activity 然后简单布局;复杂一点的基本都是 Activity + (ViewPager)Adatper + fragment, 有时候 fragment 里面还会有 ViewPager 装载着 fragment, 对于不熟悉代码的人找对应的业务逻辑页面和代码,还是需要花费不少时间的。所以页面信息 fragment 也很重要。</p><pre><code>
topActivity.getIntent().getExtras();// 获得 activity 的页面参数
topActivity.getSupportFragmentManager().getFragments(); // 获得 activity 一级中的 fragments
fragment.getChildFragmentManager().getFragments();// 对应 fragment 中的 fragments
fragment.getArguments() // 获得 fragment的页面参数
</code></pre><h2>三、 提高效率举例</h2><p>关于页面信息采集,在这里列举了几个使用场景,来证明效率得到了提高:</p><blockquote><ol><li>对于<strong>客户端开发者</strong>,能够快速的定位到当前出错的页面,特别是刚来的开发,或者不熟悉这块业务的,或者业务页面深度比较深的时候;</li><li><strong>页面核心参数的确认</strong>。比如详情页面需要一些 id,这些详细参数就不需要客户端同事打断点的方式去获取,运营和测试自己可以去查看;</li><li>在<strong>精品课和云课堂集成</strong>的时候,能够让测试同学快速的区分哪个是精品课里面的页面,哪个是云课堂里面的页面,这样方便测试知道当前页面是属于哪个业务端的;(这个场景是网易内部融合项目)</li><li><strong>页面全链路参数传递验证场景</strong>。比如:首页点击需要传递转化率的参数一直传递到下单页面,平时都是开发自己验证,有这个工具后,产品也能在提测后,从测试包上自己查看验证。</li></ol></blockquote><h2>四、 页面信息其他想法</h2><p>关于页面信息场景增强,以下几种页面信息方式,认为可以进行扩充的:</p><blockquote><ol><li>可以获得 RecyclerView 中的 adapter;(有很多布局逻辑,放到了 adapter 里面的 ViewHolder)</li><li>webview 当前信息的监控;(前端同事调试)</li><li>网络看板的监控;(当前页面的网络请求信息)</li><li>不连接电脑 Logcat 日志查看看板;(不用连接电脑,获得 adb 信息)</li><li>参考线,界面元素位置,对应元素的颜色。(UI 走查的验证)</li></ol></blockquote><p>以上的几点,主要是按照自身 app 的情况去判断是否需要实现,判断哪些实现性价比比较高。目前已经实现的,基本都是个人认为<strong>性价比是比较高</strong>的东西。</p><h2>五、 总结</h2><p>当我们遇见一个问题的时候,首先思考这个问题是不是自己比较难受的点,然后观察其他人是否有类似的情况。这个问题常用的解决方案什么?有没有工具方法去替代?如果没有,可不可以用比较低的成本去制造一个工具?最后来提升自己的效率,这个工具如果能够帮助其他人,那么能效就更加好了。</p><p><strong>-END-</strong></p>
分布式事务框架seata落地实践
https://segmentfault.com/a/1190000040212482
2021-06-21T18:29:16+08:00
2021-06-21T18:29:16+08:00
有道AI情报局
https://segmentfault.com/u/youdaotec
6
<p><img src="/img/remote/1460000040212484" alt="" title=""></p><h2>前言</h2><p>seata是阿里巴巴研发的一套开源分布式事务框架,提供了AT、TCC、SAGA 和 XA 几种事务模式。本文以精品课项目组的物流后台服务为例,介绍seata框架落地的过程,遇到的问题以及解决方案。</p><p><strong>作者/</strong> 邓新伟</p><p><strong>编辑/</strong> 网易有道</p><p>有道精品课教务系统是基于springcloud的分布式集群服务。在实际业务中,存在许多分布式事务场景。然而传统的事务框架是无法实现全局事务的。长期以来,我们的分布式场景的一致性,往往指的是放弃强一致性,保证最终一致性。</p><p>我们从调研中发现,seata框架既可以满足业务需求,灵活兼容多种事务模式,又可以实现数据强一致性。</p><p>本文以<strong>物流业务</strong>为例,记录了在实际业务中落地seata框架落地的过程中遇到的一些问题以及解决方案,供大家学习讨论~欢迎大家在留言区讨论交流</p><h2>1. 基础信息</h2><ul><li>seata版本:1.4</li><li>微服务框架:springcloud</li><li>注册中心:consul</li></ul><h2>2.基本框架</h2><h3>2.1 基本组件</h3><p>seata框架分为3个组件:</p><ul><li><strong>TC (Transaction Coordinator)</strong> -事务协调者 (即seata-server)</li></ul><p>维护全局和分支事务的状态,驱动全局事务提交或回滚。</p><ul><li><strong>TM (Transaction Manager)</strong> -事务管理器 (在client上,发起事务的服务)</li></ul><p>定义全局事务的范围:开始全局事务、提交或回滚全局事务。</p><ul><li><strong>RM (Resource Manager) -</strong> 资源管理器 (在client)</li></ul><p>管理分支事务处理的资源,与TC交谈以注册分支事务和报告分支事务的状态,并驱动分支事务提交或回滚</p><h3>2.2. 部署seata-server(TC)</h3><p>在官网下载 seata 服务端,解压后执行bin/seata-server.sh即可启动。</p><p>seata-server 有2个配置文件:registry.conf 与 file.conf。而 registry.conf 文件决定了 seata-server 使用的注册中心配置和配置信息获取方式。</p><p>我们使用 consul 做注册中心,因此需要在registry.conf文件中,需要修改以下配置:</p><pre><code>
registry {
#file 、nacos 、eureka、redis、zk、consul、etcd3、sofa
type = "consul" ## 这里注册中心填consul
loadBalance = "RandomLoadBalance"
loadBalanceVirtualNodes = 10
... ...
consul {
cluster = "seata-server"
serverAddr = "***注册中心地址***"
#这里的dc指的是datacenter,若consul为多数据源配置需要在请求中加入dc参数。
#dc与namespace并非是seata框架自带的,文章后面将会进一步解释
dc="bj-th"
namespace="seata-courseop"
}
... ...
}
config {
# file、nacos 、apollo、zk、consul、etcd3
## 如果启动时从注册中心获取基础配置信息,填consul
## 否则从file.conf文件中获取
type = "consul"
consul {
serverAddr = "127.0.0.1:8500"
}
... ...
}</code></pre><p>其中需要注意的是,如果需要高可用部署,seata获取配置信息的方式就必须是注册中心,此时file.conf就没用了。</p><p>(当然,需要事先把file.conf文件中的配置信息迁移到consul中)</p><pre><code>
store {
## store mode: file、db、redis
mode = "db"
... ...
## database store property
## 如果使用数据库模式,需要配置数据库连接设置
db {
## the implement of javax.sql.DataSource, such as DruidDataSource(druid)/BasicDataSource(dbcp)/HikariDataSource(hikari) etc.
datasource = "druid"
## mysql/oracle/postgresql/h2/oceanbase etc.
dbType = "mysql"
driverClassName = "com.mysql.jdbc.Driver"
url = "jdbc:mysql://***线上数据库地址***/seata"
user = "******"
password = "******"
minConn = 5
maxConn = 100
## 这里的三张表需要提前在数据库建好
globalTable = "global_table"
branchTable = "branch_table"
lockTable = "lock_table"
queryLimit = 100
maxWait = 5000
}
... ...
}
service {
#vgroup->rgroup
vgroupMapping.tx-seata="seata-server"
default.grouplist="127.0.0.1:8091"
#degrade current not support
enableDegrade = false
#disable
disable = false
max.commit.retry.timeout = "-1"
max.rollback.retry.timeout = "-1"
}</code></pre><p>其中,<strong>global_table</strong>, <strong>branch_table</strong>, <strong>lock_table</strong>三张表需要提前在数据库中建好。</p><h3>2.3 配置client端(RM与TM)</h3><p>每个使用seata框架的服务都需要引入seata组件</p><pre><code>
dependencies {
api 'com.alibaba:druid-spring-boot-starter:1.1.10'
api 'mysql:mysql-connector-java:6.0.6'
api('com.alibaba.cloud:spring-cloud-alibaba-seata:2.1.0.RELEASE') {
exclude group:'io.seata', module:'seata-all'
}
api 'com.ecwid.consul:consul-api:1.4.5'
api 'io.seata:seata-all:1.4.0'
}</code></pre><p>每个服务都同样需要配置file.conf与registry.conf文件,放在resource目录下。registry.conf与server的保持一致。在file.conf文件中,除了db配置外,还需要进行client参数的配置:</p><pre><code>client {
rm {
asyncCommitBufferLimit = 10000
lock {
retryInterval = 10
retryTimes = 30
retryPolicyBranchRollbackOnConflict = true
}
reportRetryCount = 5
tableMetaCheckEnable = false
reportSuccessEnable = false
}
tm {
commitRetryCount = 5
rollbackRetryCount = 5
}
undo {
dataValidation = true
logSerialization = "jackson"
## 这个undo_log也需要提前在mysql中创建
logTable = "undo_log"
}
log {
exceptionRate = 100
}
}</code></pre><p>在application.yml文件中添加seata配置:</p><pre><code>spring:
cloud:
seata: ## 注意tx-seata需要与服务端和客户端的配置文件保持一致
tx-service-group: tx-seata</code></pre><p>另外,还需要替换项目的数据源,</p><pre><code>@Primary
@Bean("dataSource")
public DataSource druidDataSource(){
DruidDataSource druidDataSource = new DruidDataSource();
druidDataSource.setUrl(url);
druidDataSource.setUsername(username);
druidDataSource.setPassword(password);
druidDataSource.setDriverClassName(driverClassName);
return new DataSourceProxy(druidDataSource);
}</code></pre><p>至此,client端的配置也已经完成了。</p><h2>3. 功能演示</h2><p>一个分布式的全局事务,整体是两阶段提交的模型。</p><p><strong>全局事务</strong>是由若干分支事务组成的,</p><p><strong>分支事务</strong>要满足两阶段提交的模型要求,即需要每个分支事务都具备自己的:</p><ul><li>一阶段 prepare 行为</li><li>二阶段commit 或 rollback 行为</li></ul><p>根据两阶段行为模式的不同,我们将分支事务划分为 <strong>Automatic (Branch) Transaction Mode</strong> 和 <strong>TCC (Branch) Transaction Mode</strong>.</p><h3>3.1 AT模式</h3><p>AT 模式基于支持本地ACID事务的关系型数据库:</p><ul><li>一阶段 <strong>prepare</strong> 行为:在本地事务中,一并提交业务数据更新和相应回滚日志记录。</li><li>二阶段 <strong>commit</strong> 行为:马上成功结束,自动异步批量清理回滚日志。</li><li>二阶段 <strong>rollback</strong> 行为:通过回滚日志,自动生成补偿操作,完成数据回滚</li></ul><blockquote>直接在需要添加全局事务的方法中加上注解@GlobalTransactional</blockquote><pre><code>
@SneakyThrows
@GlobalTransactional
@Transactional(rollbackFor = Exception.class)
public void buy(int id, int itemId){
// 先生成订单
Order order = orderFeignDao.create(id, itemId);
// 根据订单扣减账户余额
accountFeignDao.draw(id, order.amount);
}</code></pre><blockquote>注意:同@Transactional一样,@GlobalTransactional若要生效也要满足:</blockquote><ul><li>目标函数必须为public类型</li><li>同一类内方法调用时,调用目标函数的方法必须通过springBeanName.method的形式来调用,不能使用this直接调用内部方法</li></ul><h3>3.2TCC模式</h3><p>TCC 模式是支持把自定义的分支事务纳入到全局事务的管理中。</p><ul><li>一阶段 <strong>prepare</strong> 行为:调用自定义的 prepare 逻辑。</li><li>二阶段 <strong>commit</strong> 行为:调用自定义的 commit 逻辑。</li><li>二阶段 <strong>rollback</strong> 行为:调用自定义的 rollback 逻辑。</li></ul><blockquote><strong>首先</strong>编写一个TCC服务接口:</blockquote><pre><code>@LocalTCC
public interface BusinessAction {
@TwoPhaseBusinessAction(name = "doBusiness", commitMethod = "commit", rollbackMethod = "rollback")
boolean doBusiness(BusinessActionContext businessActionContext,
@BusinessActionContextParameter(paramName = "message") String msg);
boolean commit(BusinessActionContext businessActionContext);
boolean rollback(BusinessActionContext businessActionContext);
}</code></pre><blockquote><strong>其中</strong>,BusinessActionContext为全局事务上下文,可以从此对象中获取全局事务相关信息(如果是发起全局事务方,传入null后自动生成),然后实现该接口:</blockquote><pre><code>
@Slf4j
@Service
public class BusinessActionImpl implements BusinessAction {
@Transactional(rollbackFor = Exception.class)
@Override
public boolean doBusiness(BusinessActionContext businessActionContext, String msg) {
log.info("准备do business:{}",msg);
return true;
}
@Transactional(rollbackFor = Exception.class)
@Override
public boolean commit(BusinessActionContext businessActionContext) {
log.info("business已经commit");
return true;
}
@Transactional(rollbackFor = Exception.class)
@Override
public boolean rollback(BusinessActionContext businessActionContext) {
log.info("business已经rollback");
return true;
}
}</code></pre><blockquote><strong>最后</strong>,开启全局事务方法同AT模式。</blockquote><pre><code>@SneakyThrows
@GlobalTransactional
public void doBusiness(BusinessActionContext context, String msg){
accountFeignDao.draw(3, new BigDecimal(100));
businessAction.doBusiness(context, msg);
}</code></pre><h2>4. 遇到的问题</h2><h3>4.1 client TM/RM 无法注册到TC</h3><p>在部署seata项目时常常会遇到这样的问题:在本地调试时一切正常,但是当试图部署到线上时,总是在clinet端提示注册TC端失败。</p><ul><li>这是因为client需要先通过服务发现,找到注册中心里seata-server的服务信息,然后再与seata-server建立连接。不过线上的consul采用了多数据中心模式,在调用consul api时,必须加上dc参数项,否则将无法返回正确的服务信息;然而,seata提供的consul服务发现组件似乎并不支持dc参数的配置。</li><li>还有一个原因也会导致client无法连接到TC:seata的consul客户端在调用服务状态监控api时,使用了wait与index参数,从而使consul查询进入了阻塞查询模式。此时client对consul中要查询的key做监听,只有当key发生变化或者达到最大请求时间时,才会返回结果。貌似由于consul版本的问题,这个阻塞查询并没有监听到key的变化,反而会让服务发现的线程陷入无限等待之中,自然也就无法让client获取到server的注册信息了。</li></ul><h3>4.2 高可用部署</h3><p>seata服务的高可用部署<strong>只支持注册中心模式</strong>。因此,我们需要想办法将file.conf文件以键值对的形式存到consul中。</p><p>遗憾的是,consul并没有显式支持namespace,我们只能在put请求中用“/”为分隔符起到类似的效果。当然,seata框架也没有考虑到这一点。所以我们需要修改源码中的Configuration接口与RegistryProvider接口的consul实现类,增加namespace属性</p><h3>4.3global_log与branch_log</h3><p>TC在想mysql插入日志数据时,偶尔会报:</p><pre><code>Caused by: java.sql.SQLException: Incorrect string value:</code></pre><p>application_data字段其实就是对业务数据的记录。官方给出的建表语句是这样的:</p><pre><code>CREATE TABLE IF NOT EXISTS `global_table`
(
`xid` VARCHAR(128) NOT NULL,
`transaction_id` BIGINT,
`status` TINYINT NOT NULL,
`application_id` VARCHAR(32),
`transaction_service_group` VARCHAR(32),
`transaction_name` VARCHAR(128),
`timeout` INT,
`begin_time` BIGINT,
`application_data` VARCHAR(2000),
`gmt_create` DATETIME,
`gmt_modified` DATETIME,
PRIMARY KEY (`xid`),
KEY `idx_gmt_modified_status` (`gmt_modified`, `status`),
KEY `idx_transaction_id` (`transaction_id`)
) ENGINE = InnoDB
DEFAULT CHARSET = utf8;</code></pre><p>显然,VARCHAR(2000)的大小是不合适的, utf8的格式也是不合适的。所以我们需要修改seata关于<strong>数据源连接</strong>的部分代码:</p><pre><code>// connectionInitSql设置
protected Set<String> getConnectionInitSqls(){
Set<String> set = new HashSet<>();
String connectionInitSqls = CONFIG.getConfig(ConfigurationKeys.STORE_DB_CONNECTION_INIT_SQLS);
if(StringUtils.isNotEmpty(connectionInitSqls)) {
String[] strs = connectionInitSqls.split(",");
for(String s:strs){
set.add(s);
}
}
// 默认支持utf8mb4
set.add("set names utf8mb4");
return set;
}</code></pre><h2>5. 自定义开发</h2><h3>5.1 利用SPI机制编写自定义组件</h3><p>seata基于java的spi机制提供了自定义实现接口的功能,我们只需要在自己的服务中,根据seata的接口写好自己的实现类即可。</p><blockquote>SPI(Service Provider Interface)是JDK内置的服务发现机制,用在不同模块间通过接口调用服务,避免对具体服务服务接口具体实现类的耦合。比如JDBC的数据库驱动模块,不同数据库连接驱动接口相同但实现类不同,在使用SPI机制以前调用驱动代码需要直接在类里采用Class.forName(具体实现类全名)的方式调用,这样调用方依赖了具体的驱动实现,在替换驱动实现时要修改代码。</blockquote><p>以<strong>ConsulRegistryProvider</strong>为例:</p><ul><li><p>ConsulRegistryServiceImpl</p><pre><code>
// 增加DC和namespace
private static String NAMESPACE;
private static String DC;
private ConsulConfiguration() {
Config registryCongig = ConfigFactory.parseResources("registry.conf");
NAMESPACE = registryCongig.getString("config.consul.namespace");
DC = CommonSeataConfiguration.getDatacenter();
consulNotifierExecutor = new ThreadPoolExecutor(THREAD_POOL_NUM, THREAD_POOL_NUM, Integer.MAX_VALUE,
TimeUnit.MILLISECONDS, new LinkedBlockingQueue<>(),
new NamedThreadFactory("consul-config-executor", THREAD_POOL_NUM));
}
... ...
// 同时在getHealthyServices中,删除请求参数wait&index
/**
* get healthy services
*
* @param service
* @return
*/
private Response<List<HealthService>> getHealthyServices(String service, long index, long watchTimeout) {
return getConsulClient().getHealthServices(service, HealthServicesRequest.newBuilder()
.setTag(SERVICE_TAG)
.setDatacenter(DC)
.setPassing(true)
.build());
}</code></pre></li><li><p>ConsulRegistryProvider 注意order要大于seata包中的默认值1,seata类加载器会优先加载order更大的实现类</p><pre><code>@LoadLevel(name = "Consul" ,order = 2)
public class ConsulRegistryProvider implements RegistryProvider {
@Override
public RegistryService provide() {
return ConsulRegistryServiceImpl.getInstance();
}
}</code></pre></li><li><p>然后在META-INF 的services目录下添加:io.seata.discovery.registry.RegistryProvider</p><pre><code>
com.youdao.ke.courseop.common.seata.ConsulRegistryProvider
</code></pre><p>这样就可以替换seata包中的实现了。</p></li></ul><h3>5.2 common-seata工具包</h3><p>对于这些自定义实现类,以及一些公共client配置,我们可以统一封装到一个工具包下:</p><p><img src="/img/bVcSSjh" alt="图1" title="图1"></p><p>这样,其他项目只需要引入这个工具包,就可以无需繁琐的配置,直接使用了。</p><blockquote>gradle引入common包:</blockquote><pre><code>
api 'com.youdao.ke.courseop.common:common-seata:0.0.+'</code></pre><h2>6. 落地实例</h2><p>以一个物流场景为例:<br><strong>业务架构</strong>:</p><ul><li>logistics-server (物流服务)</li><li>logistics-k3c-server (物流-金蝶客户端,封装调用金蝶服务的api</li><li>elasticsearch</li></ul><p><strong>业务背景</strong>:logistics 执行领用单新增,在 elasticsearch 中更新数据,同时通过 rpc 调用 logistics-k3c 的金蝶出库方法,生成金蝶单据,如图2所示<br><img src="/img/bVcSSoz" alt="图2" title="图2"></p><p><strong>问题</strong>:如果elasticsearch单据更新出现异常,金蝶单据将无法回滚,造成数据不一致的问题。</p><p>在部署完seata线上服务后,只需要在logistics与logistics-k3c中分别引入common-seata工具包</p><p><strong>logistics服务</strong>:</p><pre><code> // 使用全局事务注解开启全局事务
@GlobalTransactional
@Transactional(rollbackFor = Exception.class)
public void Scm通过(StaffOutStockDoc staffOutStock, String body) throws Exception {
... 一些业务处理...
// 构建金蝶单据请求
K3cApi.StaffoutstockReq req = new K3cApi.StaffoutstockReq();
req.materialNums = materialNums;
req.staffOutStockId = staffOutStock.id;
... 一些业务处理 ...
// 调用logistics-k3c-api金蝶出库
k3cApi.staffoutstockAuditPass(req);
staffOutStock.status = 待发货;
staffOutStock.scmAuditTime = new Date();
staffOutStock.updateTime = new Date();
staffOutStock.historyPush("scm通过");
// 更新对象后存入elasticsearch
es.set(staffOutStock);
}</code></pre><p><strong>logistics-k3c</strong>:</p><p>由于我们新增单据接口是调用金蝶的服务,所以这里使用TCC模式构建事务接口</p><ul><li><p>首先创建StaffoutstockCreateAction接口</p><pre><code>@LocalTCC
public interface StaffoutstockCreateAction {
@TwoPhaseBusinessAction(name = "staffoutstockCreate")
boolean create(BusinessActionContext businessActionContext,
@BusinessActionContextParameter(paramName = "staffOutStock") StaffOutStock staffOutStock,
@BusinessActionContextParameter(paramName = "materialNum") List<Triple<Integer, Integer, Integer>> materialNum);
boolean commit(BusinessActionContext businessActionContext);
boolean rollback(BusinessActionContext businessActionContext);
}</code></pre></li><li><p>接口实现StaffoutstockCreateActionImpl</p><pre><code>@Slf4j
@Service
public class StaffoutstockCreateActionImpl implements StaffoutstockCreateAction {
@Autowired
private K3cAction4Staffoutstock k3cAction4Staffoutstock;
@SneakyThrows
@Transactional(rollbackFor = Exception.class)
@Override
public boolean create(BusinessActionContext businessActionContext, StaffOutStock staffOutStock, List<Triple<Integer, Integer, Integer>> materialNum) {
//金蝶单据新增
k3cAction4Staffoutstock.staffoutstockAuditPass(staffOutStock, materialNum);
return true;
}
@SneakyThrows
@Transactional(rollbackFor = Exception.class)
@Override
public boolean commit(BusinessActionContext businessActionContext) {
Map<String, Object> context = businessActionContext.getActionContext();
JSONObject staffOutStockJson = (JSONObject) context.get("staffOutStock");
// 如果尝试新增成功,commit不做任何事
StaffOutStock staffOutStock = staffOutStockJson.toJavaObject(StaffOutStock.class);
log.info("staffoutstock {} commit successfully!", staffOutStock.id);
return true;
}
@SneakyThrows
@Transactional(rollbackFor = Exception.class)
@Override
public boolean rollback(BusinessActionContext businessActionContext) {
Map<String, Object> context = businessActionContext.getActionContext();
JSONObject staffOutStockJson = (JSONObject) context.get("staffOutStock");
StaffOutStock staffOutStock = staffOutStockJson.toJavaObject(StaffOutStock.class);
// 这里调用金蝶单据删除接口进行回滚
k3cAction4Staffoutstock.staffoutstockRollback(staffOutStock);
log.info("staffoutstock {} rollback successfully!", staffOutStock.id);
return true;
}
}
</code></pre></li><li><p>封装为业务方法</p><pre><code>/**
* 项目组领用&报废的审核通过:新增其他出库单
* 该方法使用seata-TCC方案实现全局事务
* @param staffOutStock
* @param materialNum
*/
@Transactional
public void staffoutstockAuditPassWithTranscation(StaffOutStock staffOutStock,
List<Triple<Integer, Integer, Integer>> materialNum){
staffoutstockCreateAction.create(null, staffOutStock, materialNum);
}</code></pre></li><li><p>k3c API实现类</p><pre><code>
@SneakyThrows
@Override
public void staffoutstockAuditPass(StaffoutstockReq req) {
... 一些业务处理方法 ...
//这里调用了封装好的事务方法
k3cAction4Staffoutstock.staffoutstockAuditPassWithTranscation(staffOutStock, triples);
}</code></pre></li></ul><p>这样,一个<strong>基于 TCC 的全局事务链路</strong>就建立起来了。</p><p>当全局事务<strong>执行成功</strong>时,我们可以在 server 中看到打印的日志(如图3):<br><img src="/img/bVcSSpa" alt="图3" title="图3"></p><p>如果全局事务<strong>执行失败</strong>,会进行回滚,此时会执行接口中的rollback,调用金蝶接口删除生成的单据,如图4。<br><img src="/img/bVcSSpx" alt="图4" title="图4"></p><h2>7. 总结</h2><p>本文以seata框架的部署与使用为<strong>主线</strong>,记录了<strong>seata 框架</strong>运用的一些<strong>关键步骤与技术细节</strong>,并针对项目落地时遇到的一些的技术问题提供了解决方案。</p><p>在后续的推文中,我们还将继续以 seata 框架的源码解析为主线,向大家介绍 seata 实现分布式事务的核心原理与技术细节。<br><strong>-END-</strong><br><img src="https://segmentfault.com/img/remote/1460000040121238" alt="" title=""></p>
网易有道开源EMLL:高性能端侧机器学习计算库,大幅提高计算性能
https://segmentfault.com/a/1190000040176377
2021-06-15T14:41:07+08:00
2021-06-15T14:41:07+08:00
有道AI情报局
https://segmentfault.com/u/youdaotec
2
<h3>简介</h3><p>在人工智能技术不断深入发展的今天,我们对于计算的性能要求越来越高。传统的计算处理多数是基于云侧的,把所有图像、音频等数据通过网络传输到云中心进行处理后将结果反馈。但是随着数据的指数式增长,依靠云侧的计算已经显现了诸多不足,例如数据处理的实时性、网络条件制约、数据安全等,因此端侧的推理则愈发重要。</p><p>在这样的背景下,网易有道AI团队自主设计研发了高性能端侧机器学习计算库——EMLL(Edge ML Library),并已在近日开源。</p><p>EMLL 为加速端侧 AI 推理而设计,提供基于端侧处理器的高性能机器学习计算库,支持fp32、fp16、int8等数据类型,已在网易有道词典笔、翻译王和超级词典等智能硬件产品的 NMT、ASR、OCR 引擎中应用,大幅提高计算性能,提升用户体验。</p><p>开源地址:<a href="https://link.segmentfault.com/?enc=6y6QGUPGOPCzO8wK7Puo6Q%3D%3D.EYcrJwmAkKOolK3TczayOFLY5ZmBuJl3XlRg1yvwKUSPEfIQycoApwiBGJwcUuaI" rel="nofollow">https://github.com/netease-yo...</a></p><h2>一、端侧AI</h2><h3>端侧AI具有以下优势:</h3><ul><li>低延时</li><li>保证数据隐私</li><li>不依赖网络</li></ul><h3>端侧AI挑战:</h3><ul><li>处理器算力有限,远低于云端计算能力,如何满足日益复杂的端侧AI性能的需求至关重要</li><li>内存大小和带宽有限,对性能影响至关重要</li></ul><p>ARM 处理器在智能设备中占主导地位,是端侧AI落地的主流平台。NPU、DSP、GPU可以提供更高的计算能力,在端侧AI上有一定的应用场景,但生态环境较差,距离成熟还需要时间。</p><p>端侧AI最耗时的计算为全连接(FC)和卷积计算,底层核心计算为矩阵乘,底层计算库的性能对端侧AI能否落地起决定性作用。</p><h2>二、ARM第三方BLAS库</h2><h3>Eigen</h3><p>线性代数运算的 C++ 模板库,矩阵的运算可直接用符号做。</p><h3>OpenBLAS</h3><p>由中科院计算所维护的一个开源的高性能 BLAS 库,基于Kazushige Goto 的 GotoBLAS,支持 Fortran BLAS 和 CBLAS 接口调用。</p><h3>ARM Compute Library</h3><p>ARM 官方推出的计算库,支持 AI 的常见运算,其中矩阵乘法运算以模型推理层的形式封装,需要先初始化后才能调用。</p><p><strong>表1-各ARM blas库矩阵乘法特点:</strong><br><img src="/img/bVcSJQg" alt="image.png" title="image.png"></p><p>常规矩阵规模上的矩阵乘法进行了较好的优化,性能表现较好,然后在扁平矩阵上性能表现较差。端侧AI底层计算主要为扁平矩阵的乘法,第三方计算库性能表现较差,没有充分发挥硬件的性能,不利于AI应用在端侧平台上落地。</p><p><strong>表2 ARM cortex-A53四核第三方库GEMM计算效率:</strong><br><img src="/img/bVcSJQF" alt="image.png" title="image.png"></p><blockquote>注:C(M, N) = A(M, K) * B(K, N),以上值取全行主序和全列主序的最好值,测试在相同的矩阵上重复128次,计算效率由GEMM计算FLOPS值除以硬件理论FLOPS值得到。</blockquote><h2>三、EMLL特点</h2><h3>高性能</h3><p>EMLL实现的矩阵乘法函数,为端侧人工智能中常见的扁平矩阵的计算做了专门的优化,为各常见ARM处理器做了特定的优化。对于cortex-A7/A35/A53/A55/A76处理器,本库根据它们的流水线特点,使用了汇编级别的优化。</p><p>EMLL多数情况下相对Eigen、ARM compute Library第三方库性能提升明显,尤其在端侧AI常用的扁平矩阵乘法中获得数倍的性能提升。下图展示了端侧AI中部分典型矩阵尺寸情况下得单精度矩阵乘法的性能结果。<br><img src="/img/bVcSJQV" alt="image.png" title="image.png"><br><img src="/img/bVcSJQZ" alt="image.png" title="image.png"><br><strong>图1 EMLL矩阵乘法性能</strong></p><h3>易用性</h3><p>EMLL使用的函数接口在参数设计上力求简洁直接,矩阵乘法去掉了不常用的LD*参数,矩阵和向量的传递通过指针和整数维度分别传递。本库不依赖第三方计算库。</p><h3>扩展性</h3><p>对于矩阵乘法和量化函数,EMLL 库提取了它们和架构无关的代码作为通用的宏,这些宏可以在支持新的CPU架构时大大节省所需的代码量。</p><h2>四、EMLL性能优化方法</h2><p>在端侧设备上优化计算库的性能,需要从访存效率和计算效率两个角度考虑,下面以(稠密)矩阵乘法为例,介绍 EMLL 采用的优化方法。</p><h3>分块</h3><p>矩阵乘法的计算过程中需要频繁地访存。当矩阵规模较大时,CPU 缓存容量不足以装下其全部内容,访存时就会频繁出现缓存缺失,降低程序效率。此时,EMLL 会对矩阵乘法问题进行必要的拆解,把较大的矩阵切分成小块的矩阵,这就是分块的手段。经过切分,每个子任务只计算一小块矩阵对结果的贡献,只会密集访问这个小块矩阵的区域,大大提高了缓存命中率。对于两个较大矩阵之间的乘法,EMLL 参照已有的优化工作[1],通过多级的分块,充分利用 CPU 多级缓存,主要采用如下两种切分方式:<br><img src="/img/bVcSJRb" alt="image.png" title="image.png"><br><strong>图2 分块方法</strong></p><blockquote>L1 - L3 代表不同矩阵块所利用的 CPU 缓存</blockquote><p>CPU 的寄存器可以看成“速度最快的缓存”。为了充分利用寄存器,在上述分块的基础上,EMLL 进行了进一步拆分,左边的小矩阵拆成 m×k的最小矩阵 a1,右边的小矩阵拆成 k×n 的最小矩阵 b1。计算这一对最小矩阵的乘法,如果直接用三重循环的方式,需要 2×m×n×k 次元素访问,如果不利用寄存器,则都为访存操作;利用了寄存器,则只需要在乘法开始前把两个小矩阵放到寄存器中,后续的乘法就不再访存,使访存减少到 (m + n) ×k 次。</p><p>综上,大尺度的分块可以提高 CPU 各级缓存的利用率,小尺度的分块可以利用 CPU 寄存器以减少访存次数,两者对性能均有明显帮助。</p><h3>重排</h3><p>上文提到,为了充分利用寄存器,子矩阵块的读取被划分为更小的小块 m×k 或 k×n (1 < m, n, k < 20),计算中逐个读取这些小块。而通常情况下,矩阵在内存中的存储方式为行主序或列主序。无论哪种存储方式,按小块读取会存在很多跳跃访问的情况。跳跃访问对性能不利,这里列举三点:</p><ul><li>消耗额外的缓存带宽:L2/L3 缓存与 L1 的数据交互以缓存行的形式进行。跳跃访问 L2/L3 缓存的数据时,缓存行数据的利用率低,浪费传输带宽。</li><li>无法充分利用向量化的加载单元:很多支持 SIMD 的 CPU 上配备了向量化的加载单元,支持一条指令加载若干个地址连续的元素,若是跳跃访问则没法利用此特性。</li><li>增加页表查询的开销:访存操作常涉及虚拟地址向物理地址的转换,需要查询页表。一个页表的覆盖地址范围有限。如果跳跃的步长过大,则需频繁查询新的页表。</li></ul><p>在两个子矩阵块的乘法中,每个子矩阵块通常会被读取多次,每次读取的顺序可以相同。B 的子矩阵块在与它相乘的 A 块的行数多于 m时会被读多次;A 的子矩阵块在与它相乘的 B 块的列数多于 n 时会被读多次。EMLL 参照已有的优化工作1,在计算开始前,将两个子矩阵块先按计算时的读取顺序(即上一段所述按更小的小块读取)重新排列元素,这样计算时对两个子矩阵块的访问全部变成了顺序访问,此即重排的优化方法。虽然计算前重新排列元素会有额外的开销,但计算过程中对矩阵块的多次访问被顺序化后的收益更大,因此带来总体的性能提升。</p><p>对于特殊尺寸的矩阵,重排的开销可能大于收益,需要选择性地重排或不重排[2]。当源矩阵 A 的行数 M 很少而源矩阵 B 较大时,B 的子块被重复读取的次数大大降低,对 B 的子块重排的收益大大降低,甚至开始低于开销。这种情况在端侧 AI 推理中非常普遍。EMLL 会判断 M 的大小,当 M 小于一个阈值时,对矩阵 B 不再重排,而是调整计算顺序,对 B 的所有元素进行顺序的一次读取。类似地,当源矩阵 B 的列数 N 明显偏小时,EMLL 对矩阵 A 不再重排,调整计算顺序,对 A 的元素一次顺序读完。通过对特殊尺寸矩阵的特别处理,EMLL 在这些尺寸上的性能明显超过了 Eigen 和 OpenBLAS 等开源库。</p><h3>汇编优化</h3><p>现今主流的 CPU 为了提高数据计算的效率,支持了“单指令多数据”(SIMD)的处理模式,即一条指令对多个数据进行相同的操作。调用 SIMD 指令集,可以在不增加指令吞吐量的情况下,提高数据计算的吞吐量。ARM 平台提供了 NEON 指令集支持 SIMD 操作。</p><p>当 m = n = 4 而 k = 1 时,做最小矩阵小块之间的乘法并累加结果,如果使用标量计算,需要 16 次乘法和 16 次加法。NEON 指令集提供了广播模式的融合乘加操作,只需 4 条指令,即可完成相同的任务,如下图所示。其他 m,n 和 k 的取值,大多也可以用 NEON 指令加速运算。NEON 指令可以通过汇编的方式显式调用,也可通过编译器提供的 intrinsics 函数调用,后者可读性更好但性能指标的不确定性更大。</p><p>端侧的中低端平台配备的处理器,为了节省成本和功耗,在执行核心通常砍掉了乱序执行的能力,而是严格按照指令在指令流中的顺序来执行它们,如 ARM 的 cortex-A7, A35, A53, A55 等。部分型号的处理器可以在顺序执行的前提下同时执行相邻的两条指令。对于这些处理器,指令之间如果存在数据依赖或者执行单元冲突,则指令的顺序会对性能产生明显的影响,若追求极致性能,需要在汇编级别重排相关的指令。对于存在数据依赖的两条指令(比如一个运算指令的输入依赖于另一个加载指令的结果),应尽可能地使它们远离,避免因依赖关系的等待造成流水线空闲。</p><h2>五、EMLL功能</h2><h3>支持的计算函数</h3><p><strong>表3 支持的计算函数:</strong><br><img src="/img/bVcSJRr" alt="image.png" title="image.png"></p><h3>支持的架构</h3><p>armv7a, armv8a</p><h3>支持的端侧操作系统</h3><p>Linux, Android</p><h2>六、应用案例</h2><p>网易有道词典笔,是网易有道打磨的一款学习型智能硬件,凭借高效、准确查词和丰富、权威内容,成为AI技术在学习领域应用落地的优秀产品。网易有道词典笔,具有“多行扫描翻译”功能,支持整段翻译的智能学习硬件。</p><p>网易有道超级词典打造高效的智能英语学习系统,强化端侧功能,提供了拍照学英语、查词翻译、背单词、听力练习、对话翻译、语音助手等功能。</p><p>网易有道翻译王支持43种语言互译,畅游全球191个国家和地区,支持21种语言在线、7种语言端侧拍照翻译,指示牌、菜单等即拍即译。</p><p>网易有道词典笔、超级词典、翻译王均内嵌了网易有道自主研发的神经网络翻译NMT、光学字符识别OCR、语音识别ASR、语音合成TTS等业内领先的AI技术,并且支持离线功能。</p><p>网易有道自研端侧机器学习计算库已在网易有道词典笔、超级词典、翻译王等智能硬件产品中使用,带来以下好处:</p><ul><li>端到端性能相对于使用eigen库加速1.3到2.43倍,效果显著,大大降低了端侧推理引擎的延迟。除了在有道智能硬件带来了较好的性能提升,我们在配置骁龙855的某款手机上也做了性能测试,端到端性能相对于eigen提升了25%-55%,效果明显。</li><li>端侧推理引擎采用EMLL之后,可以上线更大的AI模型,提高质量,并保证实时性,如端侧NMT质量(BLEU)提升2个点,端侧ASR准确度提升4.73%。</li><li>EMLL可以保证在更低端芯片上实时性,如在cortex-A7上使用Eigen库无法达到实时性,使用EMLL之后延迟大幅降低,并保证实时性效果。EMLL可以让智能硬件更多的芯片选择,从而降低成本,提高市场竞争力。</li></ul><p><strong>表4 测试平台:</strong><br><img src="/img/bVcSJRN" alt="image.png" title="image.png"></p><p><img src="/img/bVcSJRT" alt="image.png" title="image.png"><br><strong>图3 端侧NMT、ASR、OCR在不同平台上使用EMLL和eigen端到端性能加速比</strong></p><p>EMLL 高性能端侧机器学习计算库,已经在网易有道多款智能硬件产品中实际应用并取得显著的效果,大幅提升性能,给用户带来更好的产品体验。</p><p>未来,网易有道将持续维护和优化EMLL,帮助更多企业、科研机构等伙伴提升端侧 AI 计算能力。欢迎各位开发者朋友使用并提出宝贵意见。</p><blockquote>参考文献<br>[1] Eigen:<a href="https://link.segmentfault.com/?enc=p%2FsyoQQAwkXcyDitBLuo4Q%3D%3D.bcnNP3TAiv3OX%2Foxew0Tr8e6WSQIgvdV9NUeSbKPOFg%3D" rel="nofollow">http://eigen.tuxfamily.org/</a><br>[2]OpenBlas: <a href="https://link.segmentfault.com/?enc=Kdc08tzjf6%2BGZpymk3Sb1A%3D%3D.U4jp%2Fan1r6GSex10n4tOjXK75KoEx0Uo88DjyGzQuavwm%2BHPkdgZIgTDtjXuGPH2" rel="nofollow">https://github.com/xianyi/Ope...</a><br>[3]ARMComputeLibrary: <a href="https://link.segmentfault.com/?enc=a2m26Dgc14%2BG45b1VITQSQ%3D%3D.qmDqvAC4BB40q%2F0Z4Dq%2FEHpZQy8ZGGGy%2FlXnjGcwK0TxGoxOW1WMOhsj8kuZ3GE%2F" rel="nofollow">https://github.com/ARM-softwa...</a><br>[4] Goto K., et al. Anatomy of High-Performance Matrix Multiplication[J]. ACM Trans. Math. Softw., 2008, 34(3), 12:1-12:25.<br>[5] Frison G., et al. The BLAS API of BLASFEO: optimizing performance for small matrices[J]. ACM Trans. Math. Softw., 2020, 46(2), 15:1-15:36.</blockquote><p>开源地址:<a href="https://link.segmentfault.com/?enc=ux4F0eCaGXakLiWDyW%2BjgQ%3D%3D.EAn3exnQSDQBMQcpFR7l%2FD2ecNpQeZ6m0R7JwRoBz6pblG1IhLKptcmjTfhErGmA" rel="nofollow">https://github.com/netease-yo...</a></p>
有道精品课全链路测试的改进和思考
https://segmentfault.com/a/1190000040121228
2021-06-04T15:05:00+08:00
2021-06-04T15:05:00+08:00
有道AI情报局
https://segmentfault.com/u/youdaotec
1
<h2>导读</h2><p>这篇文章主要给大家分享精品课测试团队为保证大促稳定性,在最近一年半时间的所做的一些尝试和探索。比如,如何准确预估开闸瞬间的用户流量,如何更好地进行性能优化后的验证和回测,如何解决夜深人静压测的尴尬等等。值得欣慰的是,经过持续测试和优化,精品课的所有服务,在几十亿规模的交易流量下,都表现出了很好的稳定性和可靠性。</p><blockquote><p>作者/ 有道精品课测开小组</p><p>编辑/ Ein</p></blockquote><h2>背景</h2><p>类似于电商平台的618,双11大促,在线教育平台也存在两个重要的时间节点:04月春续暑秋,10月秋续寒春,产研侧需要针对销售策略与售卖预期,提供续报工具、数据核算、流程打通等多方面的支持。作为测试人员,在保证功能可用的基础上,还要通过全链路压测的手段,维持流量突增十几倍的情况下系统的高可用状态,演练各种降级、限流、熔断、监控、应急方案,极力保证流量最高峰也不出现问题,即使出现问题也能迅速的发现-定位-处理-恢复。</p><h2>具体实践</h2><h3>整体目标</h3><p>一句话:保障开闸瞬间及活动期间系统整体稳定性。</p><p>进一步细化可以包含以下几点:</p><ol><li>容量规划:以整体流量和业务目标为基础,估算出每个子系统需要满足的容量大小,结合压测情况,对系统进行适当的扩容和优化,确保系统能够满足业务流量压力。</li><li>流量控制及降级:系统需要防止流量超过可支撑的容量,对超出设计的流量进行限流,对超负荷or异常的服务及时进行降级。这里主要验证:限流和降级策略的合理性和可用性。</li><li>监控:测试现有监控手段是否能合理发现和暴露问题,以便对问题及时预警,做到早发现,早治理。</li><li>演练预案:对系统可能面临的问题要进行全面的预演,比如基础服务异常,机房故障等等灾难模拟的手段来检验系统表现以及准备合理的处理方案。</li></ol><h3>流程</h3><p><img src="/img/remote/1460000040121230" alt="" title=""><br>图①基本测试流程</p><p><img src="/img/remote/1460000040121231" alt="" title=""><br>图②问题发现及定位</p><h3>常见问题解决方案分享</h3><h4>压测模型确定</h4><p>模型,主要包括2个方面:路径、指标。</p><p>路径:主要指的是实际活动中用户的操作路径,转化到服务即对各接口串行或者并行调用方式、调用顺序等。主要是通过从产品侧获取sop进行抓包转化得到。</p><p>指标,主要指的是各场景中各种操作的占比与时间,那从测试维度来看,就是每个接口的QPS, RT等数据。</p><ul><li>可以说模型的准确性直接关乎压测的成败,去年我们的模型中漏掉了退换课接口,但是这个接口存在比较严重的性能问题,直接触发了系统的级联故障,影响很大。 如何获取准确的模型,我们讲究“取之于实际,用之于实际”。即获取前一次续报活动的实际调用情形,通过数据清洗与整理,结合本次的预估数据量及sop,量化本次压测的指标。可参考下图:</li><li><img src="/img/remote/1460000040121232" alt="" title=""></li><li>通过我们的自研工具,完成日志自动解析、接口列表补充、压测场景确定等工作。一部分处理流程如下:(备注:npt为杭研的压测平台简称)</li><li><img src="/img/remote/1460000040121233" alt="" title=""></li><li><img src="/img/remote/1460000040121234" alt="" title=""></li><li>将SOP转化为接口路径。传统方式是抓包后,人工筛选、对比、整理抓包结果,再将接口变更情况手动同步到压测平台,该工作较繁琐且重复。这部分我们提炼成web工具后,仅需要上传抓包文件,就可以得到场景级别的接口增减情况,并支持“场景级别的接口列表维护”、“设置接口黑名单”、“接口一键导入压测平台”等功能,效果展示:</li><li><img src="/img/remote/1460000040121235" alt="" title=""></li><li><p>最终效果:不管是接口列表或者量级,我们压测模拟的流量跟实际流量几乎是一致的。</p><h4>数据构造</h4><p>数据是压测执行的前提条件,比如我们需要特定格式虚拟用户文件作为请求参数,再比如某一批用户只有拥有某些课程权限,才能算作有效用户,那么就需要给用户批量预置某些课程权限。针对这种情形,主要解决方案是:通过自研平台开发了批量操作的web工具,比如加课程权限,发放优惠券,通过多线程执行任务,在较短的时间内完成有效用户的准备,包括数据库更新、redis缓存刷新等,实现工具复用,降低造数成本。</p><h4>环境</h4><p>之前留给压测的时间比较紧张,为了保证测试结果的可靠性,我们直接使用线上环境压测,同时为了降低业务影响,只能在凌晨半夜进行测试,导致测试周期很长,相关人员都比较疲惫。经过沟通,开发和运维侧协助搭建了专属压测环境,服务部署独立实例、redis,kafka等相关中间件加prefix进行数据偏移,核心组件mysql单独部署实例,为了解决测试环境mysql数据量不够的问题及数据清理的问题,研发调研了mysql一键同步及回滚工具,流程如图:<br><img src="/img/remote/1460000040121236" alt="" title=""></p></li></ul><p>效果:核心电商业务80%的问题都可以在测试环境发现及验证,不用再熬夜线上测试。</p><h4>关于功能回测</h4><ul><li>在压测过程中,性能优化是必不可少的,那么快速完成优化后的接口功能验证是性能测试能继续进行的重要保障;实际遇到过很多种需要进行功能回测的场景,比如:需要修改接口本身逻辑,例如单查改为批量查询等;更换数据源,例如从查询es改为查询doris等,这里手工回测和我们现有的接口自动化回测都具有一定的局限性,所以我们引入了流量分析+diff方案,测试高效并且覆盖率高。</li><li>核心目标是:对比数据量多,对比速度快,操作简单方便,使用流程:</li><li><img src="/img/remote/1460000040121237" alt="" title=""></li><li>关于数据对比,满足我们要求的第三方库有很多,比如常见的deepDiff、difflib、json-diff、json_tools等,都有各自侧重点。其中DeepDiff可以对比字段、字符串等可迭代的对象,针对对象的深层差异,支持递归查找所有更改,同时支持对比的格式也很多,包括JSON、XML、图片等,因为功能比较完善并且满足我们的需求,我们最终实现选用了deepDiff库。</li></ul><h2>总结</h2><h3>收益</h3><ol><li>经过多轮压测,发现了大于20个的性能问题和优化项,在最后实际开闸期间,系统表现优秀,业务方反馈很好。</li><li>整个系统的稳定性有显著提升,日常故障率明显降低。</li><li><p>群策群力,产出了一份完善的压测作战手册作为后续大型活动压测任务的指导性文档。</p><h3>后续展望</h3></li><li>压测日常化→关于压测执行这部分,后续我们会与开发运维一起,部署一套压测专属环境,建立日常压测流程及标准,在新的变更上线前尽早发现和优化性能问题,避免临时抱佛脚。</li><li>无人值守压测→ 目前每次全链路测试,我们都需要压测执行的测试和研发人员线上实时关注监控报警等,以便及时发现和定位问题,后续希望在压测时接入我们的各种服务和接口报警系统,达到问题自动发现和产出报告的效果。</li><li>相关工具的易用性拓展,提供给开发自主回测→上面提到的一些工作,仍然存在大量的人工操作,目前我们正在做进一步的工具开发,包括diff工具增加结果展示的对比和统计,流量回放web操作页面等等。</li><li>性能瓶颈分析工具→ 当前出现性能问题时一般是靠研发人员进行人工定位和排查,后续会调研能否利用apm系统做一些问题初步分析的事情。</li><li>工具集成化,赋能其他业务线。</li></ol><p><strong>-END-</strong></p><p><img src="/img/remote/1460000040121238" alt="" title=""></p>
从源码中来,到业务中去,React性能优化终极指南
https://segmentfault.com/a/1190000040072451
2021-05-27T10:48:03+08:00
2021-05-27T10:48:03+08:00
有道AI情报局
https://segmentfault.com/u/youdaotec
2
<blockquote><p>前言:我们从React源码入手,结合有道精品课大前端的具体业务,运用三大原则对系统进行外科手术式的优化。同时介绍React Profiler这款工具如何帮我们定位性能瓶颈前言:我们从React源码入手,结合有道精品课大前端的具体业务,运用三大原则对系统进行外科手术式的优化。同时介绍React Profiler这款工具如何帮我们定位性能瓶颈</p><p>作者/ 安增平</p><p>编辑/ Ein</p></blockquote><p>React性能优化是在业务迭代过程中不得不考虑的问题,大部分情况是由于项目启动之初,没有充分考虑到项目的复杂度,定位该产品的用户体量及技术场景并不复杂,那么我们在业务前期可能并不需要考虑性能优化。但是随着业务场景的复杂化,性能优化就变得格外重要。</p><p>我们从React源码入手,结合有道精品课大前端的具体业务,运用优化技巧对系统进行外科手术式的优化。同时介绍一下React Profiler这款性能优化的利器是如何帮我们定位性能瓶颈的。</p><p>本文中的项目代码全部是在有道大前端组开发项目中的工作记录,如有不足欢迎在留言区讨论交流,笔芯❤</p><h2>页面加载流程</h2><p><img src="/img/remote/1460000040072453" alt="" title=""></p><ol><li>假设用户首次打开页面(无缓存),这个时候页面是完全空白的;</li><li>html 和引用的 css 加载完毕,浏览器进行<strong>首次渲染</strong>;</li><li>react、react-dom、业务代码加载完毕,应用第一次渲染,或者说<strong>首次内容渲染</strong>;</li><li>应用的代码开始执行,拉取数据、进行动态import、响应事件等等,完毕后页面进入<strong>可交互</strong>状态;</li><li>接下来 lazyload 的图片等多媒体内容开始逐渐加载完毕;</li><li>直到页面的其它资源(如错误上报组件、打点上报组件等)加载完毕,整个页面加载完成。</li></ol><p><strong>我们主要来针对React进行剖析</strong>:</p><p>React 针对渲染性能优化的三个方向,也适用于其他软件开发领域,这三个方向分别是:</p><ol><li><strong>减少计算的量</strong>:React 中就是减少渲染的节点或通过索引减少渲染复杂度;</li><li><strong>利用缓存</strong>:React 中就是避免重新渲染(利用 memo 方式来避免组件重新渲染);</li><li><strong>精确重新计算的范围</strong>:React 中就是绑定组件和状态关系, 精确判断更新的'时机'和'范围'. 只重新渲染变更的组件(减少渲染范围)。</li></ol><p>如何做到这三点呢?我们从React本身的特性入手分析。</p><h2>React 工作流</h2><p>React 是声明式 UI 库,负责将 State 转换为页面结构(虚拟 DOM 结构)后,再转换成真实 DOM 结构,交给浏览器渲染。State 发生改变时,React 会先进行Reconciliation,结束后立刻进入Commit阶段,Commit结束后,新 State 对应的页面才被展示出来。</p><p><img src="/img/remote/1460000040072454" alt="" title=""></p><p>React 的<strong>Reconciliation</strong>需要做两件事:</p><ol><li>计算出目标 State 对应的虚拟 DOM 结构。</li><li>寻找「将虚拟 DOM 结构修改为目标虚拟 DOM 结构」的最优方案。</li></ol><p>React 按照深度优先遍历虚拟 DOM 树的方式,在一个虚拟 DOM 上完成Render和Diff的计算后,再计算下一个虚拟 DOM。Diff 算法会记录虚拟 DOM 的更新方式(如:Update、Mount、Unmount),为Commit做准备。</p><p>React 的<strong>Commit</strong>也需要做两件事:</p><ol><li>将Reconciliation结果应用到 DOM 中。</li><li>调用暴露的hooks如:componentDidUpdate、useLayoutEffect 等。</li></ol><p><strong>下面我们将针对三个优化方向进行精准分析。</strong></p><h2>减少计算的量</h2><p>关于以上<strong>Reconciliation</strong>与<strong>Commit</strong>两个阶段的优化办法,我在实现的过程中遵循<strong>减少计算量</strong>的方法进行优化(<strong>列表项使用 key 属性</strong>)该过程是优化的重点,React 内部的 Fiber 结构和并发模式也是在减少该过程的耗时阻塞。对于<strong>Commit</strong>在执行hooks时,开发者应保证hooks中的代码尽量轻量,避免耗时阻塞,同时应避免在 <strong>CDM、CDU</strong>周期中更新组件。</p><p><strong>列表项使用 key 属性</strong></p><blockquote>特定框架中,提示也做的十分友好。假如你没有在列表中添加key属性,控制台会为你展示一片大红</blockquote><p><img src="/img/remote/1460000040072455" alt="" title=""></p><p>系统会时刻提醒你记得加Key哦~~</p><h3>优化Render 过程</h3><p>Render 过程:即Reconciliation中计算出目标 State 对应的虚拟 DOM 结构这一阶段 。</p><p>触发 React 组件的 Render 过程目前有三种方式:</p><ol><li>forceUpdate、</li><li>State 更新、</li><li>父组件 Render 触发子组件 Render 过程。</li></ol><h4>优化技巧</h4><h5>PureComponent、React.memo</h5><p>在 React 工作流中,如果只有父组件发生状态更新,即使父组件传给子组件的所有 Props 都没有修改,也会引起子组件的 Render 过程。</p><p>从 React 的声明式设计理念来看,如果子组件的 Props 和 State 都没有改变,那么其生成的 DOM 结构和副作用也不应该发生改变。当子组件符合声明式设计理念时,就可以忽略子组件本次的 Render 过程。</p><p>PureComponent 和 React.memo 就是应对这种场景的,PureComponent 是对类组件的 Props 和 State 进行浅比较,React.memo 是对函数组件的 Props 进行浅比较。</p><h6>useMemo、useCallback 实现稳定的 Props 值</h6><p>如果传给子组件的派生状态或函数,每次都是新的引用,那么 PureComponent 和 React.memo 优化就会失效。所以需要使用 useMemo 和 useCallback 来生成稳定值,并结合 PureComponent 或 React.memo 避免子组件重新 Render。</p><h6>useMemo 减少组件 Render 过程耗时</h6><p>useMemo 是一种缓存机制提速,当它的依赖未发生改变时,就不会触发重新计算。一般用在「计算派生状态的代码」非常耗时的场景中,如:遍历大列表做统计信息。</p><p><img src="/img/remote/1460000040072456" alt="大列表渲染" title="大列表渲染"></p><p>显然useMemo的作用是缓存昂贵的计算(避免在每次渲染时都进行高开销的计算),在业务中使用它去控制变量来更新表格</p><h5>shouldComponentUpdate</h5><p>在类组件中,例如要往数组中添加一项数据时,当时的代码很可能是 state.push(item),而不是 const newState = [...state, item]。</p><p><img src="/img/remote/1460000040072457" alt="" title=""></p><p>在此背景下,当时的开发者经常使用 </p><p>shouldComponentUpdate 来深比较 Props,只在 Props 有修改才执行组件的 Render 过程。如今由于数据不可变性和函数组件的流行,这样的优化场景已经不会再出现了。</p><p>为了贴合shouldComponentUpdate的思想:给子组件传props的时候一定只传其需要的而并非一股脑全部传入:</p><p><img src="/img/remote/1460000040072458" alt="" title=""></p><p>传入到子组件的参数一定保证其在自组件中被使用到。</p><h3>批量更新,减少 Render 次数</h3><p>在 React 管理的事件回调和生命周期中,setState 是异步的,而其他时候 setState 都是同步的。这个问题根本原因就是 React 在自己管理的事件回调和生命周期中,对于 setState 是批量更新的,而在其他时候是立即更新的。</p><p>批量更新 setState 时,多次执行 setState 只会触发一次 Render 过程。相反在立即更新 setState 时,每次 setState 都会触发一次 Render 过程,就存在性能影响。</p><p>假设有如下组件代码,该组件在 getData() 的 API 请求结果返回后,分别更新了两个 State 。</p><p><img src="/img/remote/1460000040072459" alt="" title=""></p><p>该组件会在 setList(data.list) 后触发组件的 Render 过程,然后在 setInfo(data.info) 后再次触发 Render 过程,造成性能损失。那我们该如何解决呢:</p><ol><li>将多个 State 合并为单个 State。例如 useState({ list: null, info: null }) 替代 list 和 info 两个 State。</li><li>使用 React 官方提供的 unstable_batchedUpdates 方法,将多次 setState 封装到 unstable_batchedUpdates 回调中。</li></ol><p>修改后代码如下:</p><p><img src="/img/remote/1460000040072460" alt="" title=""></p><h2>精细化渲染阶段</h2><h3>按优先级更新,及时响应用户</h3><p>优先级更新是批量更新的逆向操作,其思想是:优先响应用户行为,再完成耗时操作。常见的场景是:页面弹出一个 Modal,当用户点击 Modal 中的确定按钮后,代码将执行两个操作:</p><ol><li>关闭 Modal。</li><li>页面处理 Modal 传回的数据并展示给用户。</li></ol><p>当操作2需要执行500ms时,用户会明显感觉到从点击按钮到 Modal 被关闭之间的延迟。</p><p>以下为一般的实现方式,将 slowHandle 函数作为用户点击按钮的回调函数。</p><p><img src="/img/remote/1460000040072461" alt="" title=""></p><p>slowHandle() 执行过程耗时长,用户点击按钮后会明显感觉到页面卡顿。</p><p>如果让页面优先隐藏输入框,用户便能立刻感知到页面更新,不会有卡顿感。</p><p>实现优先级更新的要点是将耗时任务移动到下一个宏任务中执行,优先响应用户行为。</p><p>例如在该例中,将 setNumbers 移动到 setTimeout 的回调中,用户点击按钮后便能立即看到输入框被隐藏,不会感知到页面卡顿。mhd项目中优化后的代码如下:</p><p><img src="/img/remote/1460000040072462" alt="" title=""></p><h3>发布者订阅者跳过中间组件 Render 过程</h3><p>React 推荐将公共数据放在所有「需要该状态的组件」的公共祖先上,但将状态放在公共祖先上后,该状态就需要层层向下传递,直到传递给使用该状态的组件为止。</p><p><img src="/img/remote/1460000040072463" alt="" title=""></p><p>每次状态的更新都会涉及中间组件的 Render 过程,但中间组件并不关心该状态,它的 Render 过程只负责将该状态再传给子组件。在这种场景下可以将状态用发布者订阅者模式维护,只有关心该状态的组件才去订阅该状态,不再需要中间组件传递该状态。</p><p>当状态更新时,发布者发布数据更新消息,只有订阅者组件才会触发 Render 过程,中间组件不再执行 Render 过程。</p><p>只要是发布者订阅者模式的库,都可以使用useContext进行该优化。比如:redux、use-global-state、React.createContext 等。</p><p>业务代码中的使用如下:</p><p><img src="/img/remote/1460000040072464" alt="" title=""></p><p>从图中可看出,优化后只有使用了公共状态的组件renderTable才会发生更新,由此可见这样做可以大大减少父组件和 其他renderSon... 组件的 Render 次数(减少叶子节点的重渲染)。</p><h3>useMemo 返回虚拟 DOM 可跳过该组件 Render 过程</h3><p>利用 useMemo 可以缓存计算结果的特点,如果 useMemo 返回的是组件的虚拟 DOM,则将在 useMemo 依赖不变时,跳过组件的 Render 阶段。</p><p>该方式与 React.memo 类似,但与 React.memo 相比有以下优势:</p><ol><li>更方便。React.memo 需要对组件进行一次包装,生成新的组件。而 useMemo 只需在存在性能瓶颈的地方使用,不用修改组件。</li><li>更灵活。useMemo 不用考虑组件的所有 Props,而只需考虑当前场景中用到的值,也可使用 useDeepCompareMemo 对用到的值进行深比较。</li></ol><p>该例子中,父组件状态更新后,不使用 useMemo 的子组件会执行 Render 过程,而使用 useMemo 的子组件会按需执行更新。业务代码中的使用方法:</p><p><img src="/img/remote/1460000040072465" alt="" title=""></p><h2>精确判断更新的'时机'和'范围'</h2><h3>debounce、throttle 优化频繁触发的回调</h3><p>在搜索组件中,当 input 中内容修改时就触发搜索回调。当组件能很快处理搜索结果时,用户不会感觉到输入延迟。</p><p>但实际场景中,中后台应用的列表页非常复杂,组件对搜索结果的 Render 会造成页面卡顿,明显影响到用户的输入体验。</p><p>在搜索场景中一般使用 useDebounce+ useEffect 的方式获取数据。</p><p><img src="/img/remote/1460000040072466" alt="" title=""></p><p>在搜索场景中,只需响应用户最后一次输入,无需响应用户的中间输入值,debounce 更适合。而 throttle 更适合需要实时响应用户的场景中更适合,如通过拖拽调整尺寸或通过拖拽进行放大缩小(如:window 的 resize 事件)。</p><h3>懒加载</h3><p>在 SPA 中,懒加载优化一般用于从一个路由跳转到另一个路由。</p><p>还可用于用户操作后才展示的复杂组件,比如点击按钮后展示的弹窗模块(大数据量弹窗)。</p><p>在这些场景下,结合 Code Split 收益较高。懒加载的实现是通过 Webpack 的动态导入和 React.lazy 方法。</p><p>实现懒加载优化时,不仅要考虑加载态,还需要对加载失败进行容错处理。</p><p><img src="/img/remote/1460000040072467" alt="" title=""></p><h3>懒渲染</h3><p>懒渲染指当组件进入或即将进入可视区域时才渲染组件。常见的组件 Modal/Drawer 等,当 visible 属性为 true 时才渲染组件内容,也可以认为是懒渲染的一种实现。懒渲染的使用场景有:</p><ol><li>页面中出现多次的组件,且组件渲染费时、或者组件中含有接口请求。如果渲染多个带有请求的组件,由于浏览器限制了同域名下并发请求的数量,就可能会阻塞可见区域内的其他组件中的请求,导致可见区域的内容被延迟展示。</li><li>需用户操作后才展示的组件。这点和懒加载一样,但懒渲染不用动态加载模块,不用考虑加载态和加载失败的兜底处理,实现上更简单。</li></ol><p>懒渲染的实现中判断组件是否出现在可视区域内借助react-visibility-observer依赖:</p><p><img src="/img/remote/1460000040072468" alt="" title=""></p><h3>虚拟列表</h3><p>虚拟列表是懒渲染的一种特殊场景。虚拟列表的组件有 react-window和 react-virtualized,它们都是同一个作者开发的。</p><p>react-window 是 react-virtualized 的轻量版本,其 API 和文档更加友好。推荐使用 react-window,只需要计算每项的高度即可:</p><p><img src="/img/remote/1460000040072469" alt="" title=""></p><p>如果每项的高度是变化的,可给 itemSize 参数传一个函数。</p><p>所以在开发过程中,遇到接口返回的是所有数据时,需提前预防这类会有展示的性能瓶颈的需求时,推荐使用虚拟列表优化。使用示例:<a href="https://link.segmentfault.com/?enc=JU%2FGbnVvlxf%2BgRqmmXLvsQ%3D%3D.xFcoM9bPh3ptra5cA6eD8frgXsFpUvTCWKeGJvBhmwYEcx3FDZ8rsvBCt0OzZvsU9m5re8%2FsnmeqPVSv5nAyWA%3D%3D" rel="nofollow">react-windowreact-window.vercel.app</a></p><h3>动画库直接修改 DOM 属性,跳过组件 Render 阶段</h3><p>这个优化在业务中应该用不上,但还是非常值得学习的,将来可以应用到组件库中。</p><p>参考 react-spring 的动画实现,当一个动画启动后,每次动画属性改变不会引起组件重新 Render ,而是直接修改了 dom 上相关属性值:</p><p><img src="/img/remote/1460000040072470" alt="" title=""></p><h3>避免在 didMount、didUpdate 中更新组件 State</h3><p>这个技巧不仅仅适用于 didMount、didUpdate,还包括 willUnmount、useLayoutEffect 和特殊场景下的 useEffect(当父组件的 cDU/cDM 触发时,子组件的 useEffect 会同步调用),本文为叙述方便将他们统称为「提交阶段钩子」。</p><p><strong>React 工作流commit</strong>阶段的第二步就是执行提交阶段钩子,它们的执行会阻塞浏览器更新页面。</p><p>如果在提交阶段钩子函数中更新组件 State,会再次触发组件的更新流程,造成两倍耗时。一般在提交阶段的钩子中更新组件状态的场景有:</p><ol><li>计算并更新组件的派生状态(Derived State)。在该场景中,类组件应使用 <strong>getDerivedStateFromProps</strong> 钩子方法代替,函数组件应使用函数调用时执行 <strong>setState</strong> 的方式代替。使用上面两种方式后,React 会将新状态和派生状态在一次更新内完成。</li><li>根据 DOM 信息,修改组件状态。在该场景中,除非想办法不依赖 DOM 信息,否则两次更新过程是少不了的,就只能用其他优化技巧了。</li></ol><p>use-swr 的源码就使用了该优化技巧。当某个接口存在缓存数据时,use-swr 会先使用该接口的缓存数据,并在 requestIdleCallback 时再重新发起请求,获取最新数据。模拟一个swr:</p><p><img src="/img/remote/1460000040072471" alt="" title=""></p><ol><li>它的第二个参数 deps,是为了在请求带有参数时,如果参数改变了就重新发起请求。</li><li>暴露给调用方的 fetch 函数,可以应对主动刷新的场景,比如页面上的刷新按钮。</li></ol><p>如果 use-swr 不做该优化的话,就会在 useLayoutEffect 中触发重新验证并设置 isValidating 状态为 true·,引起组件的更新流程,造成性能损失。</p><h2>工具介绍——React Profiler</h2><h3>React Profiler 定位 Render 过程瓶颈</h3><p>React Profiler 是 React 官方提供的性能审查工具,本文只介绍笔者的使用心得,详细的使用手册请移步官网文档。</p><p>Note:react-dom 16.5+ 在 DEV 模式下才支持 Profiling,同时生产环境下也可以通过一个 profiling bundle react-dom/profiling 来支持。请在 fb.me/react-profi… 上查看如何使用这个 bundle。</p><p>“Profiler” 的面板在刚开始的时候是空的。你可以点击 record 按钮来启动 profile:</p><p><img src="/img/remote/1460000040072472" alt="" title=""></p><h3>Profiler 只记录了 Render 过程耗时</h3><p>不要通过 Profiler 定位非 Render 过程的性能瓶颈问题</p><p>通过 React Profiler,开发者可以查看组件 Render 过程耗时,但无法知晓提交阶段的耗时。</p><p>尽管 Profiler 面板中有 Committed at 字段,但这个字段是相对于录制开始时间,根本没有意义。</p><p>通过在 React v16 版本上进行实验,同时开启 Chrome 的 Performance 和 React Profiler 统计。</p><p>如下图,在 Performance 面板中,Reconciliation和Commit阶段耗时分别为 642ms 和 300ms,而 Profiler 面板中只显示了 642ms:</p><p><img src="/img/remote/1460000040072473" alt="" title=""></p><h3>开启「记录组件更新原因」</h3><p>点击面板上的齿轮,然后勾选「Record why each component rendered while profiling.」,如下图:</p><p><img src="/img/remote/1460000040072474" alt="" title=""></p><p>然后点击面板中的虚拟 DOM 节点,右侧便会展示该组件重新 Render 的原因。</p><h3>定位产生本次 Render 过程原因</h3><p>由于 React 的批量更新(Batch Update)机制,产生一次 Render 过程可能涉及到很多个组件的状态更新。那么如何定位是哪些组件状态更新导致的呢?</p><p><img src="/img/remote/1460000040072475" alt="" title=""></p><p>在 Profiler 面板左侧的虚拟 DOM 树结构中,从上到下审查每个发生了渲染的(不会灰色的)组件。</p><p>如果组件是由于 State 或 Hook 改变触发了 Render 过程,那它就是我们要找的组件,如下图:</p><p><img src="/img/remote/1460000040072476" alt="" title=""></p><h2>站在巨人的肩膀上</h2><p><a href="https://link.segmentfault.com/?enc=BBzljO4RJ07BragVDW%2B%2BAg%3D%3D.PWJq8Y2%2B7eJBRAj6rldKNdipCyWf50mkCNiSqEBr%2FWROkG4rUyyHyoCS6d0LFNCrla%2Bn1KFxXYMKjaZ3sX7xpQ%3D%3D" rel="nofollow">Optimizing Performance React</a> 官方文档,最好的教程, 利用好 React 的性能分析工具。</p><p><a href="https://link.segmentfault.com/?enc=kCBiokNuKB6Smgp%2FA3OMnA%3D%3D.DzlcbP0tZJ2AxXxgYja0Nom%2FJ3YVQOLtMN26ac16vGBoibqYD%2FHUcMTeD2cViQhZdUXH3%2FkoPRris5nvoUnerU4QQNzZxwuiRyafwlilNe1wFcpjrHSQkTUFg3EsQpZTFXS0PsapTITa6Z%2BO873iEXYbQjrJRn3VZNPmhuAhWUw%3D" rel="nofollow">Twitter Lite and High Performance React Progressive Web Apps at Scale </a>看看 Twitter 如何优化的。</p><p><strong>-END-</strong></p>
网易云课堂 Service Worker 运用与实践
https://segmentfault.com/a/1190000040032791
2021-05-20T11:05:33+08:00
2021-05-20T11:05:33+08:00
有道AI情报局
https://segmentfault.com/u/youdaotec
4
<h2>前言</h2><p>本文首先会简单介绍下前端的常见缓存方式,再引入Service Worker的概念,针对其原理和如何运用进行介绍。然后基于google推出的第三方库Workbox,在产品中进行运用实践,并对其原理进行简要剖析。</p><blockquote><p>作者:刘放</p><p>编辑:Ein</p></blockquote><h2>前端缓存简介</h2><p>先简单介绍一下现有的前端缓存技术方案,主要分为http缓存和浏览器缓存。</p><h3>http缓存</h3><p>http缓存都是第二次请求时开始的,这也是个老生常谈的话题了。无非也是那几个http头的问题:</p><h4>Expires</h4><p>HTTP1.0的内容,服务器使用Expires头来告诉Web客户端它可以使用当前副本,直到指定的时间为止。</p><h4>Cache-Control</h4><p>HTTP1.1引入了Cathe-Control,它使用max-age指定资源被缓存多久,主要是解决了Expires一个重大的缺陷,就是它设置的是一个固定的时间点,客户端时间和服务端时间可能有误差。<br>所以一般会把两个头都带上,这种缓存称为强缓存,表现形式为:<br><img src="/img/remote/1460000040032793" alt="" title=""></p><h4>Last-Modified / If-Modified-Since</h4><p>Last-Modified是服务器告诉浏览器该资源的最后修改时间,If-Modified-Since是请求头带上的,上次服务器给自己的该资源的最后修改时间。然后服务器拿去对比。</p><p>若资源的最后修改时间大于If-Modified-Since,说明资源又被改动过,则响应整片资源内容,返回状态码200;</p><p>若资源的最后修改时间小于或等于If-Modified-Since,说明资源无新修改,则响应HTTP 304,告知浏览器继续使用当前版本。</p><h4>Etag / If-None-Match</h4><p>前面提到由文件的修改时间来判断文件是否改动,还是会带来一定的误差,比如注释等无关紧要的修改等。所以推出了新的方式。</p><p>Etag是由服务端特定算法生成的该文件的唯一标识,而请求头把返回的Etag值通过If-None-Match再带给服务端,服务端通过比对从而决定是否响应新内容。这也是304缓存。</p><h3>浏览器缓存</h3><h4>Storage</h4><p>简单的缓存方式有cookie,localStorage和sessionStorage。这里就不详细介绍他们的区别了,这里说下通过localStorage来缓存静态资源的优化方案。<br>localStorage通常有5MB的存储空间,我们以微信文章页为例。<br>查看请求发现,基本没有js和css的请求,因为它把全部的不需要改动的资源都放到了localStorage中:<br><img src="/img/remote/1460000040032794" alt="" title=""><br>所以微信的文章页加载非常的快。</p><h4>前端数据库</h4><p>前端数据库有WebSql和IndexDB,其中WebSql被规范废弃,他们都有大约50MB的最大容量,可以理解为localStorage的加强版。</p><h4>应用缓存</h4><p>应用缓存主要是通过manifest文件来注册被缓存的静态资源,已经被废弃,因为他的设计有些不合理的地方,他在缓存静态文件的同时,也会默认缓存html文件。这导致页面的更新只能通过manifest文件中的版本号来决定。所以,应用缓存只适合那种常年不变化的静态网站。如此的不方便,也是被废弃的重要原因。</p><p>PWA也运用了该文件,不同于manifest简单的将文件通过是否缓存进行分类,PWA用manifest构建了自己的APP骨架,并运用Servie Worker来控制缓存,这也是今天的主角。</p><h2>Service Worker</h2><p>Service Worker本质上也是浏览器缓存资源用的,只不过他不仅仅是Cache,也是通过worker的方式来进一步优化。<br>他基于h5的web worker,所以绝对不会阻碍当前js线程的执行,sw最重要的工作原理就是:</p><p>1、后台线程:独立于当前网页线程;</p><p>2、网络代理:在网页发起请求时代理,来缓存文件。</p><h3>兼容性</h3><p><img src="/img/remote/1460000040032795" alt="" title=""><br>可以看到,基本上新版浏览器还是兼容滴。之前是只有chrome和firefox支持,现在微软和苹果也相继支持了。</p><h3>成熟程度</h3><p>判断一个技术是否值得尝试,肯定要考虑下它的成熟程度,否则过一段时间又和应用缓存一样被规范抛弃就尴尬了。<br>所以这里我列举了几个使用Service Worker的页面:</p><ul><li>淘宝</li><li>网易新闻</li><li>考拉</li></ul><p>所以说还是可以尝试下的。</p><h3>调试方法</h3><p>一个网站是否启用Service Worker,可以通过开发者工具中的Application来查看:</p><p><img src="/img/remote/1460000040032796" alt="" title=""></p><p>被Service Worker缓存的文件,可以在Network中看到Size项为from Service Worker:</p><p><img src="/img/remote/1460000040032797" alt="" title=""></p><p>也可以在Application的Cache Storage中查看缓存的具体内容:</p><p><img src="/img/remote/1460000040032798" alt="" title=""></p><p>如果是具体的断点调试,需要使用对应的线程,不再是main线程了,这也是webworker的通用调试方法:</p><p><img src="/img/remote/1460000040032799" alt="" title=""></p><h3>使用条件</h3><p>sw 是基于 HTTPS 的,因为Service Worker中涉及到请求拦截,所以必须使用HTTPS协议来保障安全。如果是本地调试的话,localhost是可以的。<br>而我们刚好全站强制https化,所以正好可以使用。</p><h3>生命周期</h3><p>大概可以用如下图片来解释:</p><p><img src="/img/remote/1460000040032800" alt="" title=""></p><h4>注册</h4><p>要使用Service Worker,首先需要注册一个sw,通知浏览器为该页面分配一块内存,然后sw就会进入安装阶段。<br>一个简单的注册方式:</p><pre><code>(function() {
if('serviceWorker' in navigator) {
navigator.serviceWorker.register('./sw.js');
}
})()</code></pre><p>当然也可以考虑全面点,参考网易新闻的注册方式:</p><pre><code>"serviceWorker" in navigator && window.addEventListener("load",
function() {
var e = location.pathname.match(/\/news\/[a-z]{1,}\//)[0] + "article-sw.js?v=08494f887a520e6455fa";
navigator.serviceWorker.register(e).then(function(n) {
n.onupdatefound = function() {
var e = n.installing;
e.onstatechange = function() {
switch (e.state) {
case "installed":
navigator.serviceWorker.controller ? console.log("New or updated content is available.") : console.log("Content is now available offline!");
break;
case "redundant":
console.error("The installing service worker became redundant.")
}
}
}
}).
catch(function(e) {
console.error("Error during service worker registration:", e)
})
})</code></pre><p>前面提到过,由于sw会监听和代理所有的请求,所以sw的作用域就显得额外的重要了,比如说我们只想监听我们专题页的所有请求,就在注册时指定路径:</p><pre><code>navigator.serviceWorker.register('/topics/sw.js');</code></pre><p>这样就只会对topics/下面的路径进行优化。</p><h4>installing</h4><p>我们注册后,浏览器就会开始安装sw,可以通过事件监听:</p><pre><code>//service worker安装成功后开始缓存所需的资源
var CACHE_PREFIX = 'cms-sw-cache';
var CACHE_VERSION = '0.0.20';
var CACHE_NAME = CACHE_PREFIX+'-'+CACHE_VERSION;
var allAssets = [
'./main.css'
];
self.addEventListener('install', function(event) {
//调试时跳过等待过程
self.skipWaiting();
// Perform install steps
//首先 event.waitUntil 你可以理解为 new Promise,
//它接受的实际参数只能是一个 promise,因为,caches 和 cache.addAll 返回的都是 Promise,
//这里就是一个串行的异步加载,当所有加载都成功时,那么 SW 就可以下一步。
//另外,event.waitUntil 还有另外一个重要好处,它可以用来延长一个事件作用的时间,
//这里特别针对于我们 SW 来说,比如我们使用 caches.open 是用来打开指定的缓存,但开启的时候,
//并不是一下就能调用成功,也有可能有一定延迟,由于系统会随时睡眠 SW,所以,为了防止执行中断,
//就需要使用 event.waitUntil 进行捕获。另外,event.waitUntil 会监听所有的异步 promise
//如果其中一个 promise 是 reject 状态,那么该次 event 是失败的。这就导致,我们的 SW 开启失败。
event.waitUntil(
caches.open(CACHE_NAME)
.then(function(cache) {
console.log('[SW]: Opened cache');
return cache.addAll(allAssets);
})
);
});</code></pre><p>安装时,sw就开始缓存文件了,会检查所有文件的缓存状态,如果都已经缓存了,则安装成功,进入下一阶段。</p><h4>activated</h4><p>如果是第一次加载sw,在安装后,会直接进入activated阶段,而如果sw进行更新,情况就会显得复杂一些。流程如下:</p><p>首先老的sw为A,新的sw版本为B。<br>B进入install阶段,而A还处于工作状态,所以B进入waiting阶段。只有等到A被terminated后,B才能正常替换A的工作。</p><p><img src="/img/remote/1460000040032801" alt="" title=""></p><p>这个terminated的时机有如下几种方式:</p><p>1、关闭浏览器一段时间;</p><p>2、手动清除Service Worker;</p><p>3、在sw安装时直接跳过waiting阶段</p><pre><code>//service worker安装成功后开始缓存所需的资源
self.addEventListener('install', function(event) {
//跳过等待过程
self.skipWaiting();
});</code></pre><p>然后就进入了activated阶段,激活sw工作。</p><p>activated阶段可以做很多有意义的事情,比如更新存储在Cache中的key和value:</p><pre><code>var CACHE_PREFIX = 'cms-sw-cache';
var CACHE_VERSION = '0.0.20';
/**
* 找出对应的其他key并进行删除操作
* @returns {*}
*/
function deleteOldCaches() {
return caches.keys().then(function (keys) {
var all = keys.map(function (key) {
if (key.indexOf(CACHE_PREFIX) !== -1 && key.indexOf(CACHE_VERSION) === -1){
console.log('[SW]: Delete cache:' + key);
return caches.delete(key);
}
});
return Promise.all(all);
});
}
//sw激活阶段,说明上一sw已失效
self.addEventListener('activate', function(event) {
event.waitUntil(
// 遍历 caches 里所有缓存的 keys 值
caches.keys().then(deleteOldCaches)
);
});</code></pre><h4>idle</h4><p>这个空闲状态一般是不可见的,这种一般说明sw的事情都处理完毕了,然后处于闲置状态了。</p><p>浏览器会周期性的轮询,去释放处于idle的sw占用的资源。</p><h4>fetch</h4><p>该阶段是sw最为关键的一个阶段,用于拦截代理所有指定的请求,并进行对应的操作。</p><p>所有的缓存部分,都是在该阶段,这里举一个简单的例子:</p><pre><code>//监听浏览器的所有fetch请求,对已经缓存的资源使用本地缓存回复
self.addEventListener('fetch', function(event) {
event.respondWith(
caches.match(event.request)
.then(function(response) {
//该fetch请求已经缓存
if (response) {
return response;
}
return fetch(event.request);
}
)
);
});</code></pre><p>生命周期大概讲清楚了,我们就以一个具体的例子来说明下原生的serviceworker是如何在生产环境中使用的吧。</p><h3>举个栗子</h3><p>我们可以以网易新闻的wap页为例,其针对不怎么变化的静态资源开启了sw缓存,具体的sw.js逻辑和解读如下:</p><pre><code>'use strict';
//需要缓存的资源列表
var precacheConfig = [
["https://static.ws.126.net/163/wap/f2e/milk_index/bg_img_sm_minfy.png",
"c4f55f5a9784ed2093009dadf1e954f9"],
["https://static.ws.126.net/163/wap/f2e/milk_index/change.png",
"9af1b102ef784b8ff08567ba25f31d95"],
["https://static.ws.126.net/163/wap/f2e/milk_index/icon-download.png",
"1c02c724381d77a1a19ca18925e9b30c"],
["https://static.ws.126.net/163/wap/f2e/milk_index/icon-login-dark.png",
"b59ba5abe97ff29855dfa4bd3a7a9f35"],
["https://static.ws.126.net/163/wap/f2e/milk_index/icon-refresh.png",
"a5b1084e41939885969a13f8dbc88abd"],
["https://static.ws.126.net/163/wap/f2e/milk_index/icon-video-play.png",
"065ff496d7d36345196d254aff027240"],
["https://static.ws.126.net/163/wap/f2e/milk_index/icon.ico",
"a14e5365cc2b27ec57e1ab7866c6a228"],
["https://static.ws.126.net/163/wap/f2e/milk_index/iconfont_1.eot",
"e4d2788fef09eb0630d66cc7e6b1ab79"],
["https://static.ws.126.net/163/wap/f2e/milk_index/iconfont_1.svg",
"d9e57c341608fddd7c140570167bdabb"],
["https://static.ws.126.net/163/wap/f2e/milk_index/iconfont_1.ttf",
"f422407038a3180bb3ce941a4a52bfa2"],
["https://static.ws.126.net/163/wap/f2e/milk_index/iconfont_1.woff",
"ead2bef59378b00425779c4ca558d9bd"],
["https://static.ws.126.net/163/wap/f2e/milk_index/index.5cdf03e8.js",
"6262ac947d12a7b0baf32be79e273083"],
["https://static.ws.126.net/163/wap/f2e/milk_index/index.bc729f8a.css",
"58e54a2c735f72a24715af7dab757739"],
["https://static.ws.126.net/163/wap/f2e/milk_index/logo-app-bohe.png",
"ac5116d8f5fcb3e7c49e962c54ff9766"],
["https://static.ws.126.net/163/wap/f2e/milk_index/logo-app-mail.png",
"a12bbfaeee7fbf025d5ee85634fca1eb"],
["https://static.ws.126.net/163/wap/f2e/milk_index/logo-app-manhua.png",
"b8905b119cf19a43caa2d8a0120bdd06"],
["https://static.ws.126.net/163/wap/f2e/milk_index/logo-app-open.png",
"b7cc76ba7874b2132f407049d3e4e6e6"],
["https://static.ws.126.net/163/wap/f2e/milk_index/logo-app-read.png",
"e6e9c8bc72f857960822df13141cbbfd"],
["https://static.ws.126.net/163/wap/f2e/milk_index/logo-site.png",
"2b0d728b46518870a7e2fe424e9c0085"],
["https://static.ws.126.net/163/wap/f2e/milk_index/version_no_pic.png",
"aef80885188e9d763282735e53b25c0e"],
["https://static.ws.126.net/163/wap/f2e/milk_index/version_pc.png",
"42f3cc914eab7be4258fac3a4889d41d"],
["https://static.ws.126.net/163/wap/f2e/milk_index/version_standard.png",
"573408fa002e58c347041e9f41a5cd0d"]
];
var cacheName = 'sw-precache-v3-new-wap-index-' + (self.registration ? self.registration.scope : '');
var ignoreUrlParametersMatching = [/^utm_/];
var addDirectoryIndex = function(originalUrl, index) {
var url = new URL(originalUrl);
if (url.pathname.slice(-1) === '/') {
url.pathname += index;
}
return url.toString();
};
var cleanResponse = function(originalResponse) {
// If this is not a redirected response, then we don't have to do anything.
if (!originalResponse.redirected) {
return Promise.resolve(originalResponse);
}
// Firefox 50 and below doesn't support the Response.body stream, so we may
// need to read the entire body to memory as a Blob.
var bodyPromise = 'body' in originalResponse ?
Promise.resolve(originalResponse.body) :
originalResponse.blob();
return bodyPromise.then(function(body) {
// new Response() is happy when passed either a stream or a Blob.
return new Response(body, {
headers: originalResponse.headers,
status: originalResponse.status,
statusText: originalResponse.statusText
});
});
};
var createCacheKey = function(originalUrl, paramName, paramValue,
dontCacheBustUrlsMatching) {
// Create a new URL object to avoid modifying originalUrl.
var url = new URL(originalUrl);
// If dontCacheBustUrlsMatching is not set, or if we don't have a match,
// then add in the extra cache-busting URL parameter.
if (!dontCacheBustUrlsMatching ||
!(url.pathname.match(dontCacheBustUrlsMatching))) {
url.search += (url.search ? '&' : '') +
encodeURIComponent(paramName) + '=' + encodeURIComponent(paramValue);
}
return url.toString();
};
var isPathWhitelisted = function(whitelist, absoluteUrlString) {
// If the whitelist is empty, then consider all URLs to be whitelisted.
if (whitelist.length === 0) {
return true;
}
// Otherwise compare each path regex to the path of the URL passed in.
var path = (new URL(absoluteUrlString)).pathname;
return whitelist.some(function(whitelistedPathRegex) {
return path.match(whitelistedPathRegex);
});
};
var stripIgnoredUrlParameters = function(originalUrl,
ignoreUrlParametersMatching) {
var url = new URL(originalUrl);
// Remove the hash; see https://github.com/GoogleChrome/sw-precache/issues/290
url.hash = '';
url.search = url.search.slice(1) // Exclude initial '?'
.split('&') // Split into an array of 'key=value' strings
.map(function(kv) {
return kv.split('='); // Split each 'key=value' string into a [key, value] array
})
.filter(function(kv) {
return ignoreUrlParametersMatching.every(function(ignoredRegex) {
return !ignoredRegex.test(kv[0]); // Return true iff the key doesn't match any of the regexes.
});
})
.map(function(kv) {
return kv.join('='); // Join each [key, value] array into a 'key=value' string
})
.join('&'); // Join the array of 'key=value' strings into a string with '&' in between each
return url.toString();
};
var hashParamName = '_sw-precache';
//定义需要缓存的url列表
var urlsToCacheKeys = new Map(
precacheConfig.map(function(item) {
var relativeUrl = item[0];
var hash = item[1];
var absoluteUrl = new URL(relativeUrl, self.location);
var cacheKey = createCacheKey(absoluteUrl, hashParamName, hash, false);
return [absoluteUrl.toString(), cacheKey];
})
);
//把cache中的url提取出来,进行去重操作
function setOfCachedUrls(cache) {
return cache.keys().then(function(requests) {
//提取url
return requests.map(function(request) {
return request.url;
});
}).then(function(urls) {
//去重
return new Set(urls);
});
}
//sw安装阶段
self.addEventListener('install', function(event) {
event.waitUntil(
//首先尝试取出存在客户端cache中的数据
caches.open(cacheName).then(function(cache) {
return setOfCachedUrls(cache).then(function(cachedUrls) {
return Promise.all(
Array.from(urlsToCacheKeys.values()).map(function(cacheKey) {
//如果需要缓存的url不在当前cache中,则添加到cache
if (!cachedUrls.has(cacheKey)) {
//设置same-origin是为了兼容旧版本safari中其默认值不为same-origin,
//只有当URL与响应脚本同源才发送 cookies、 HTTP Basic authentication 等验证信息
var request = new Request(cacheKey, {credentials: 'same-origin'});
return fetch(request).then(function(response) {
//通过fetch api请求资源
if (!response.ok) {
throw new Error('Request for ' + cacheKey + ' returned a ' +
'response with status ' + response.status);
}
return cleanResponse(response).then(function(responseToCache) {
//并设置到当前cache中
return cache.put(cacheKey, responseToCache);
});
});
}
})
);
});
}).then(function() {
//强制跳过等待阶段,进入激活阶段
return self.skipWaiting();
})
);
});
self.addEventListener('activate', function(event) {
//清除cache中原来老的一批相同key的数据
var setOfExpectedUrls = new Set(urlsToCacheKeys.values());
event.waitUntil(
caches.open(cacheName).then(function(cache) {
return cache.keys().then(function(existingRequests) {
return Promise.all(
existingRequests.map(function(existingRequest) {
if (!setOfExpectedUrls.has(existingRequest.url)) {
//cache中删除指定对象
return cache.delete(existingRequest);
}
})
);
});
}).then(function() {
//self相当于webworker线程的当前作用域
//当一个 service worker 被初始注册时,页面在下次加载之前不会使用它。 claim() 方法会立即控制这些页面
//从而更新客户端上的serviceworker
return self.clients.claim();
})
);
});
self.addEventListener('fetch', function(event) {
if (event.request.method === 'GET') {
// 标识位,用来判断是否需要缓存
var shouldRespond;
// 对url进行一些处理,移除一些不必要的参数
var url = stripIgnoredUrlParameters(event.request.url, ignoreUrlParametersMatching);
// 如果该url不是我们想要缓存的url,置为false
shouldRespond = urlsToCacheKeys.has(url);
// 如果shouldRespond未false,再次验证
var directoryIndex = 'index.html';
if (!shouldRespond && directoryIndex) {
url = addDirectoryIndex(url, directoryIndex);
shouldRespond = urlsToCacheKeys.has(url);
}
// 再次验证,判断其是否是一个navigation类型的请求
var navigateFallback = '';
if (!shouldRespond &&
navigateFallback &&
(event.request.mode === 'navigate') &&
isPathWhitelisted([], event.request.url)) {
url = new URL(navigateFallback, self.location).toString();
shouldRespond = urlsToCacheKeys.has(url);
}
// 如果标识位为true
if (shouldRespond) {
event.respondWith(
caches.open(cacheName).then(function(cache) {
//去缓存cache中找对应的url的值
return cache.match(urlsToCacheKeys.get(url)).then(function(response) {
//如果找到了,就返回value
if (response) {
return response;
}
throw Error('The cached response that was expected is missing.');
});
}).catch(function(e) {
// 如果没找到则请求该资源
console.warn('Couldn\'t serve response for "%s" from cache: %O', event.request.url, e);
return fetch(event.request);
})
);
}
}
});</code></pre><p>这里的策略大概就是优先在Cache中寻找资源,如果找不到再请求资源。可以看出,为了实现一个较为简单的缓存,还是比较复杂和繁琐的,所以很多工具就应运而生了。</p><h2>Workbox</h2><p>由于直接写原生的sw.js,比较繁琐和复杂,所以一些工具就出现了,而Workbox是其中的佼佼者,由google团队推出。</p><h3>简介</h3><p>在 Workbox 之前,GoogleChrome 团队较早时间推出过 sw-precache 和 sw-toolbox 库,但是在 GoogleChrome 工程师们看来,workbox 才是真正能方便统一的处理离线能力的更完美的方案,所以停止了对 sw-precache 和 sw-toolbox 的维护。</p><h3>使用者</h3><p>有很多团队也是启用该工具来实现serviceworker的缓存,比如说:</p><ul><li>淘宝首页</li><li>网易新闻wap文章页</li><li>百度的Lavas</li></ul><h3>基本配置</h3><p>首先,需要在项目的sw.js文件中,引入Workbox的官方js,这里用了我们自己的静态资源:</p><pre><code>importScripts(
"https://edu-cms.nosdn.127.net/topics/js/workbox_9cc4c3d662a4266fe6691d0d5d83f4dc.js"
);</code></pre><p>其中importScripts是webworker中加载js的方式。</p><p>引入Workbox后,全局会挂载一个Workbox对象</p><pre><code>if (workbox) {
console.log('workbox加载成功');
} else {
console.log('workbox加载失败');
}</code></pre><p>然后需要在使用其他的api前,提前使用配置</p><pre><code>//关闭控制台中的输出
workbox.setConfig({ debug: false });</code></pre><p>也可以统一指定存储时Cache的名称:</p><pre><code>//设置缓存cachestorage的名称
workbox.core.setCacheNameDetails({
prefix:'edu-cms',
suffix:'v1'
});</code></pre><h3>precache</h3><p>Workbox的缓存分为两种,一种的precache,一种的runtimecache。</p><p>precache对应的是在installing阶段进行读取缓存的操作。它让开发人员可以确定缓存文件的时间和长度,以及在不进入网络的情况下将其提供给浏览器,这意味着它可以用于创建Web离线工作的应用。</p><h4>工作原理</h4><p>首次加载Web应用程序时,Workbox会下载指定的资源,并存储具体内容和相关修订的信息在indexedDB中。</p><p>当资源内容和sw.js更新后,Workbox会去比对资源,然后将新的资源存入Cache,并修改indexedDB中的版本信息。</p><p>我们举一个例子:</p><pre><code>workbox.precaching.precacheAndRoute([
'./main.css'
]);</code></pre><p><img src="/img/remote/1460000040032802" alt="" title=""></p><p>indexedDB中会保存其相关信息</p><p><img src="/img/remote/1460000040032803" alt="" title=""></p><p>这个时候我们把main.css的内容改变后,再刷新页面,会发现除非强制刷新,否则Workbox还是会读取Cache中存在的老的main.css内容。</p><p>即使我们把main.css从服务器上删除,也不会对页面造成影响。</p><p>所以这种方式的缓存都需要配置一个版本号。在修改sw.js时,对应的版本也需要变更。</p><h4>使用实践</h4><p>当然了,一般我们的一些不经常变的资源,都会使用cdn,所以这里自然就需要支持域外资源了,配置方式如下:</p><pre><code>var fileList = [
{
url:'https://edu-cms.nosdn.127.net/topics/js/cms_specialWebCommon_js_f26c710bd7cd055a64b67456192ed32a.js'
},
{
url:'https://static.ws.126.net/163/frontend/share/css/article.207ac19ad70fd0e54d4a.css'
}
];
//precache 适用于支持跨域的cdn和域内静态资源
workbox.precaching.suppressWarnings();
workbox.precaching.precacheAndRoute(fileList, {
"ignoreUrlParametersMatching": [/./]
});</code></pre><p>这里需要对应的资源配置跨域允许头,否则是不能正常加载的。且文件都要以版本文件名的方式,来确保修改后Cache和indexDB会得到更新。</p><p>理解了原理和实践后,说明这种方式适合于上线后就不会经常变动的静态资源。</p><h3>runtimecache</h3><p>运行时缓存是在install之后,activated和fetch阶段做的事情。</p><p>既然在fetch阶段发送,那么runtimecache 往往应对着各种类型的资源,对于不同类型的资源往往也有不同的缓存策略。</p><h4>缓存策略</h4><p>Workbox提供的缓存策划有以下几种,通过不同的配置可以针对自己的业务达到不同的效果:</p><h4>Stale While Revalidate</h4><p>这种策略的意思是当请求的路由有对应的Cache缓存结果就直接返回,</p><p>在返回Cache缓存结果的同时会在后台发起网络请求拿到请求结果并更新Cache缓存,如果本来就没有Cache缓存的话,直接就发起网络请求并返回结果,这对用户来说是一种非常安全的策略,能保证用户最快速的拿到请求的结果。</p><p>但是也有一定的缺点,就是还是会有网络请求占用了用户的网络带宽。可以像如下的方式使用State While Revalidate策略:</p><pre><code>workbox.routing.registerRoute(
new RegExp('https://edu-cms\.nosdn\.127\.net/topics/'),
workbox.strategies.staleWhileRevalidate({
//cache名称
cacheName: 'lf-sw:static',
plugins: [
new workbox.expiration.Plugin({
//cache最大数量
maxEntries: 30
})
]
})
);</code></pre><h4>Network First</h4><p>这种策略就是当请求路由是被匹配的,就采用网络优先的策略,也就是优先尝试拿到网络请求的返回结果,如果拿到网络请求的结果,就将结果返回给客户端并且写入Cache缓存。</p><p>如果网络请求失败,那最后被缓存的Cache缓存结果就会被返回到客户端,这种策略一般适用于返回结果不太固定或对实时性有要求的请求,为网络请求失败进行兜底。可以像如下方式使用Network First策略:</p><pre><code>//自定义要缓存的html列表
var cacheList = [
'/Hexo/public/demo/PWADemo/workbox/index.html'
];
workbox.routing.registerRoute(
//自定义过滤方法
function(event) {
// 需要缓存的HTML路径列表
if (event.url.host === 'localhost:63342') {
if (~cacheList.indexOf(event.url.pathname)) return true;
else return false;
} else {
return false;
}
},
workbox.strategies.networkFirst({
cacheName: 'lf-sw:html',
plugins: [
new workbox.expiration.Plugin({
maxEntries: 10
})
]
})
);</code></pre><h4>Cache First</h4><p>这个策略的意思就是当匹配到请求之后直接从Cache缓存中取得结果,如果Cache缓存中没有结果,那就会发起网络请求,拿到网络请求结果并将结果更新至Cache缓存,并将结果返回给客户端。这种策略比较适合结果不怎么变动且对实时性要求不高的请求。可以像如下方式使用Cache First策略:</p><pre><code>workbox.routing.registerRoute(
new RegExp('https://edu-image\.nosdn\.127\.net/'),
workbox.strategies.cacheFirst({
cacheName: 'lf-sw:img',
plugins: [
//如果要拿到域外的资源,必须配置
//因为跨域使用fetch配置了
//mode: 'no-cors',所以status返回值为0,故而需要兼容
new workbox.cacheableResponse.Plugin({
statuses: [0, 200]
}),
new workbox.expiration.Plugin({
maxEntries: 40,
//缓存的时间
maxAgeSeconds: 12 * 60 * 60
})
]
})
);</code></pre><h4>Network Only</h4><p>比较直接的策略,直接强制使用正常的网络请求,并将结果返回给客户端,这种策略比较适合对实时性要求非常高的请求。</p><h4>Cache Only</h4><p>这个策略也比较直接,直接使用 Cache 缓存的结果,并将结果返回给客户端,这种策略比较适合一上线就不会变的静态资源请求。</p><h3>举个栗子</h3><p>又到了举个栗子的阶段了,这次我们用淘宝好了,看看他们是如何通过Workbox来配置Service Worker的:</p><pre><code>//首先是异常处理
self.addEventListener('error', function(e) {
self.clients.matchAll()
.then(function (clients) {
if (clients && clients.length) {
clients[0].postMessage({
type: 'ERROR',
msg: e.message || null,
stack: e.error ? e.error.stack : null
});
}
});
});
self.addEventListener('unhandledrejection', function(e) {
self.clients.matchAll()
.then(function (clients) {
if (clients && clients.length) {
clients[0].postMessage({
type: 'REJECTION',
msg: e.reason ? e.reason.message : null,
stack: e.reason ? e.reason.stack : null
});
}
});
})
//然后引入workbox
importScripts('https://g.alicdn.com/kg/workbox/3.3.0/workbox-sw.js');
workbox.setConfig({
debug: false,
modulePathPrefix: 'https://g.alicdn.com/kg/workbox/3.3.0/'
});
//直接激活跳过等待阶段
workbox.skipWaiting();
workbox.clientsClaim();
//定义要缓存的html
var cacheList = [
'/',
'/tbhome/home-2017',
'/tbhome/page/market-list'
];
//html采用networkFirst策略,支持离线也能大体访问
workbox.routing.registerRoute(
function(event) {
// 需要缓存的HTML路径列表
if (event.url.host === 'www.taobao.com') {
if (~cacheList.indexOf(event.url.pathname)) return true;
else return false;
} else {
return false;
}
},
workbox.strategies.networkFirst({
cacheName: 'tbh:html',
plugins: [
new workbox.expiration.Plugin({
maxEntries: 10
})
]
})
);
//静态资源采用staleWhileRevalidate策略,安全可靠
workbox.routing.registerRoute(
new RegExp('https://g\.alicdn\.com/'),
workbox.strategies.staleWhileRevalidate({
cacheName: 'tbh:static',
plugins: [
new workbox.expiration.Plugin({
maxEntries: 20
})
]
})
);
//图片采用cacheFirst策略,提升速度
workbox.routing.registerRoute(
new RegExp('https://img\.alicdn\.com/'),
workbox.strategies.cacheFirst({
cacheName: 'tbh:img',
plugins: [
new workbox.cacheableResponse.Plugin({
statuses: [0, 200]
}),
new workbox.expiration.Plugin({
maxEntries: 20,
maxAgeSeconds: 12 * 60 * 60
})
]
})
);
workbox.routing.registerRoute(
new RegExp('https://gtms01\.alicdn\.com/'),
workbox.strategies.cacheFirst({
cacheName: 'tbh:img',
plugins: [
new workbox.cacheableResponse.Plugin({
statuses: [0, 200]
}),
new workbox.expiration.Plugin({
maxEntries: 30,
maxAgeSeconds: 12 * 60 * 60
})
]
})
);</code></pre><p>可以看出,使用Workbox比起直接手撸来,要快很多,也明确很多。</p><h3>原理</h3><p>目前分析Service Worker和Workbox的文章不少,但是介绍Workbox原理的文章却不多。这里简单介绍下Workbox这个工具库的原理。</p><p>首先将几个我们产品用到的模块图奉上:</p><p><img src="/img/remote/1460000040032804" alt="" title=""></p><p>简单提几个Workbox源码的亮点。</p><h4>通过Proxy按需依赖</h4><p>熟悉了Workbox后会得知,它是有很多个子模块的,各个子模块再通过用到的时候按需importScript到线程中。<br><img src="/img/remote/1460000040032805" alt="" title=""></p><p>做到按需依赖的原理就是通过Proxy对全局对象Workbox进行代理:</p><pre><code>new Proxy(this, {
get(t, s) {
//如果workbox对象上不存在指定对象,就依赖注入该对象对应的脚本
if (t[s]) return t[s];
const o = e[s];
return o && t.loadModule(`workbox-${o}`), t[s];
}
})</code></pre><p>如果找不到对应模块,则通过importScripts主动加载:</p><pre><code>/**
* 加载前端模块
* @param {Strnig} t
*/
loadModule(t) {
const e = this.o(t);
try {
importScripts(e), (this.s = !0);
} catch (s) {
throw (console.error(`Unable to import module '${t}' from '${e}'.`), s);
}
}</code></pre><h4>通过freeze冻结对外暴露api</h4><p>Workbox.core模块中提供了几个核心操作模块,如封装了indexedDB操作的DBWrapper、对Cache Storage进行读取的Cache Wrapper,以及发送请求的fetchWrapper和日志管理的logger等等。</p><p>为了防止外部对内部模块暴露出去的api进行修改,导致出现不可预估的错误,内部模块可以通过Object.freeze将api进行冻结保护:</p><pre><code>var _private = /*#__PURE__*/Object.freeze({
DBWrapper: DBWrapper,
WorkboxError: WorkboxError,
assert: finalAssertExports,
cacheNames: cacheNames,
cacheWrapper: cacheWrapper,
fetchWrapper: fetchWrapper,
getFriendlyURL: getFriendlyURL,
logger: defaultExport
});</code></pre><h2>总结</h2><p>通过对Service Worker的理解和Workbox的应用,可以进一步提升产品的性能和弱网情况下的体验。有兴趣的同学也可以对Workbox的源码细细评读,其中还有很多不错的设计模式和编程风格值得学习。<br><strong>-END-</strong></p><hr><p><img src="/img/remote/1460000040032806" alt="" title=""></p><h2>有道技术沙龙</h2><p><img src="/img/bVcR5u1" alt="" title=""></p>
有道技术沙龙 | JAVA下午茶
https://segmentfault.com/a/1190000039999805
2021-05-14T11:25:29+08:00
2021-05-14T11:25:29+08:00
有道AI情报局
https://segmentfault.com/u/youdaotec
2
<p>度过了愉快的五一假期</p><p>是不是急需知识充电?</p><p>有道技术沙龙系列第二期</p><p>正式启动啦!</p><p>第二期给大家带来</p><p><strong>JAVA相关技术干货</strong></p><p>长按扫描下图二维码报名参与~</p><p><img src="/img/remote/1460000040021176" alt="" title=""></p><p>长按扫描上图二维码</p><p>或点击<a href="https://link.segmentfault.com/?enc=b9iL7wUcBEvo8S6LK7N50g%3D%3D.iSdnILgh4DV3RxCFn5tQ6i8DZ2LEl%2FALCHdeRFo6OUKOjKEeP6f8Cbhg%2B2mIjRRGvPVXVmECo4XPz2NGeGmGrg%3D%3D" rel="nofollow">【报名链接】</a>报名参与~</p><p>当然</p><p><strong>干货多多,福利多多</strong>的沙龙</p><p>怎么会少了相关福利呢?</p><p>除了精美伴手礼<strong>到场即送</strong></p><p>还有升级的<strong>抽奖福利</strong></p><p><img src="/img/remote/1460000039999808" alt="" title=""></p><p>更有美味茶歇交流</p><p>学习干货,结识小伙伴</p><p>享受轻松下午茶~</p><p><img src="/img/remote/1460000039999809" alt="" title=""></p><p><img src="/img/remote/1460000039999810" alt="" title=""> 除了上述“大礼包”</p><p>还有更重要的<strong>有道直通车</strong></p><p>BOSS面对面,岗位内推…</p><p><em>P.S 上期有道技术沙龙的小伙伴中,已经有人通过直通车方式入职有道啦</em></p><p>更多岗位信息,内推信息将会分享在</p><p><strong>有道技术交流群中</strong></p><p>感兴趣的小伙伴可以添加</p><p>小助手微信(ydtech01)加入交流群</p><p>快到碗里来~</p><hr><h3>活动信息</h3><p>时间:5月22日 下午 13:30-17:00</p><p>地点:中关村 P2 · E世界游乐园</p><p><img src="/img/remote/1460000039999811" alt="" title=""></p><p><strong>-END-</strong></p>
网易云课堂个性化推荐实践与思考
https://segmentfault.com/a/1190000039918740
2021-04-28T15:02:29+08:00
2021-04-28T15:02:29+08:00
有道AI情报局
https://segmentfault.com/u/youdaotec
1
<blockquote><strong>作者/</strong> 韩虹莹 <br><strong>编辑/</strong> Ein</blockquote><h2>从人和信息的博弈谈推荐系统缘起</h2><p>首先谈谈我理解的推荐系统。</p><p>如果说推荐系统的定义是什么,每本书每篇文章说的都不太一样,协同过滤1992年就已经有了,三十年里无数大佬分析了个性化推荐的缘起和意义,世界已经不需要多一个人的见解。但是,当所有人都说一件事情是正确的时候,我们也要想清楚<strong>它为什么是正确的</strong>。</p><p>如果你问我推荐系统是什么,我会告诉你,<strong>是信息到人的精准分发</strong>。那么为什么在这个时代推荐系统才应运而生?古人不会需要信息精准分发,车马信息都很慢,古人学富五车不过现在一个书包的信息量;唯有现在人才需要信息精准分发,信息太多时间太少,乱花渐欲迷人眼,所以我们需要一个智能的系统,帮助你过来过滤信息,所以推荐系统是人和信息的桥梁。</p><p>当然,正如罗马不是一天建成的一样,在互联网上搭个桥也是要演进的,最开始是个小木桥——门户网站,用分类导航分发了信息;后来演化到了石板桥——搜索引擎,人可以更精准的找信息;逐步的信息太多了,要变成信息找人,在这个过程中,无论是信息的消费者,还是信息的生产者,都遇到了不曾预见的困难,信息消费者找不到信息了,信息生产者无法让自己的信息展现在消费者眼前,有痛点就有需求,有需求就有产品,于是推荐系统作为一个产品,恰到好处又必然的到来。凯文凯利在《必然》里,把这个趋势称为“<strong>过滤</strong>”:</p><blockquote>进行过滤是必然的,因为我们在不停地制造新东西。而在我们将要制造的新东西中,首要的一点就是创造新的方式来过滤信息和个性化定制,以突显我们之间的差异。</blockquote><p>人如何和信息相处,推荐系统既不是起点,恐怕也不会是终局,但它已经是当前人们对于处理信息所能做的最好的实践了。</p><h2>推荐系统要如何满足需求</h2><p>推荐系统应该单独作为一个产品来看,他是一个什么产品呢?作为一个加工信息的产品,它一定要满足信息供需两端的需求,才有价值。</p><p>所以作为一个推荐系统,要把自己定义在一个<strong>中间方的位置</strong>,可以说 C 端用户和产品经理都是你的用户,两端的需求都需要被满足,所以既需要你想技术方案,还需要你去想,你怎么更好的满足两端的需求,用户只需要你精准的帮他找到信息。而对于产品方,需要挖掘想通过推荐系统获得什么。</p><p>对于用户端(<strong>信息需求端</strong>),最迫切的需求是如何帮我精准的找到我需要的信息。</p><p>对于公司端(<strong>信息供应端</strong>),是为了满足一些商业化的需求,比如吸引用户,增强用户黏性,提高用户转化率,比如资讯平台,短视频平台,信息流平台希望提升用户活跃度,延长用户停留时间,电商平台希望提高用户购买转化率。</p><h2>推荐系统常规架构</h2><p><img src="/img/remote/1460000039918742" alt="" title=""></p><p>从上图来看,一个完整的推荐系统包括数据部分和模型部分,数据部分主要依赖大数据离线或在线处理平台,主要完成的工作包括数据的收集和 ETL 处理,生成推荐模型所需要的特征数据。</p><p>推荐系统模型部分是主体,这部分要在提供推荐模型服务之前,完成模型的训练,然后对输入数据进行处理,通过不同的召回或排序策略,生成最后的输出结果。一个常规的工业级推荐系统,在模型部分主要包括召回层,过滤层,排序层,也可根据业务需要判断是否需要补充策略与算法层。</p><p><strong>1. "召回层"</strong><br>一般利用高效的召回规则、算法或简单的模型,快速从海量的候选集中召回用户可能感兴趣的物品。 </p><p><strong>2. "过滤层"</strong><br>一般根据特定场景业务需求,对召回的数据进行过滤。</p><p><strong>3. "排序层"</strong><br>利用排序模型对初筛的候选集进行精排序。</p><p><strong>4. "补充策略与算法层"</strong><br>也被称为"再排序层",可以在将推荐列表返回用户之前,为兼顾结果的"多样性" "流行度" "新鲜度"等指标,结合一些补充的策 略和算法对推荐列表进行一定的调整,最终形成用户可见的推荐列表。</p><h2>推荐系统常见模型概述与比较</h2><p>先来一个推荐算法发展的时间线</p><p><img src="/img/remote/1460000039918743" alt="" title=""></p><p>可以从图中看出,2016年是推荐系统从传统机器学习模型到深度学习模型的转折点,这一年微软的 Deep Crossing ,谷歌的 Wide&Deep ,以及 FNN 、 PNN 等一大批 优秀的深度学习推荐模型相继推出,继而逐渐成为推荐系统的主流。但传统推荐模型仍然要被重视,第一它们是深度学习的基础,很多东西都是一脉相承的,矩阵分解的隐向量思想在Embedding中继续发展,FM中的核心思想——特征交叉也在深度学习中继续使用,逻辑回归可以看做神经元的另一种表现形式。第二这些算法的硬件要求低,结果可解释性强,训练容易,仍是大量场景所适用的。</p><h3>机器学习推荐模型演化过程</h3><p><img src="/img/remote/1460000039918744" alt="" title=""></p><p>可以从图中看出,2016年是推荐系统从传统机器学习模型到深度学习模型的转折点,这一年微软的 Deep Crossing ,谷歌的 Wide&Deep ,以及 FNN 、 PNN 等一大批 优秀的深度学习推荐模型相继推出,继而逐渐成为推荐系统的主流。但传统推荐模型仍然要被重视,第一它们是深度学习的基础,很多东西都是一脉相承的,矩阵分解的隐向量思想在Embedding中继续发展,FM中的核心思想——特征交叉也在深度学习中继续使用,逻辑回归可以看做神经元的另一种表现形式。第二这些算法的硬件要求低,结果可解释性强,训练容易,仍是大量场景所适用的。</p><h3>协同过滤</h3><p>协同过滤是推荐系统领域应用最广泛的模型了,而且大家一说到推荐系统,就会直接关联到协同过滤,而且是基于用户的协同过滤 or 基于物品的协同过滤,其实从某种程度上理解,矩阵分解也是协同过滤的一种,基于用户,商品的协同过滤属于基于近邻的协同过滤,从更大一点的范围来说,大多数机器学习和分类算法可以理解为协同过滤的一个分支,协同过滤可以看做是分类问题的泛化,正是因为这个原因,适用于分类的许多模型也可以通过泛化应用于协同过滤。</p><p>本节主要针对的,还是广泛意义上理解的,基于近邻的协同过滤,这类协同过滤,其实就是<strong>基于用户-用户,物品-物品的相似度计算</strong>。</p><h4>基于用户协同过滤</h4><p>当用户需要个性化推荐时,可以先找到与他相似其他用户(通过兴趣、爱好或行为习惯等,然后把那些用户喜欢的并且自己不知道的物品推荐给用户。</p><p>步骤:</p><ul><li>准备用户向量,在该矩阵中,理论上每个用户得到一个向量</li><li>向量维度是物品个数,向量是稀疏的,向量取值可以是简单的0或1</li><li>用每个用户向量,两两计算用户之间相似度,设定一个相似度阈值,为每个用户保留与其最相似的用户</li><li>为每个用户产生推荐结果</li></ul><h4>基于物品协同过滤</h4><p>基于物品的协同过滤算法简称,其简单应用情景是:当一个用户需要个性化推荐时,例如由于他之前购买过金庸的《射雕英雄传》这本书,所以会给他推荐《神雕侠侶》,因为其他用户很多都同时购买了这两本书。</p><p>步骤:</p><ul><li>构建用户物品的关系矩阵,可以是购买行为,或购买后的评价,购买次数等</li><li>两两计算物品相似度,得到物品相似度矩阵</li><li>产生推荐结果,典型的两种形式:①为某个物品推荐相关物品;②个人首页的“猜你喜欢”</li></ul><p>计算相似度的方式有如下几种:</p><p>①余弦相似度,余弦相似度( Cosine Similarity )衡量了用户向量t和用户向量j之间的向量夹角大小。 显然,夹角越小,证明余弦相似 度越大,两个用户越相似。</p><p><img src="/img/remote/1460000039918745" alt="" title=""></p><p>②皮尔逊相关系数,相比余弦相似度,皮尔逊相关系数通过使用用户平均分对各独立评分进行修正,减小了用户评分偏置的影响 。</p><p><img src="/img/remote/1460000039918746" alt="" title=""></p><h3>矩阵分解</h3><p>关于矩阵分解,一种讲法把它划归为协同过滤,认为他是基于模型的协同过滤,另一种则认为他是协同过滤的进化,其实这个到影响不大,矩阵分解是在协同过滤的基础上,增加了隐向量的概念。</p><p>矩阵分解可以解决一些邻域模型无法解决的问题:①物品之间存在关联性,信息量不随向量维度线性增加;②矩阵元素稀疏,计算结果不稳定,增减一个向量,结果差异很大。</p><p>矩阵分解把User矩阵和Item矩阵作为未知量,用它们表示出每个用户对每个item的预测评分,然后通过最小化预测评分跟实际评分的差异,学习出User矩阵和Item矩阵。也就是说,图2中只有等号左边的矩阵是已知的,等号右边的User矩阵和Item矩阵都是未知量,由矩阵分解通过最小化预测评分跟实际评分的差异学出来的。</p><p><img src="/img/remote/1460000039918747" alt="" title=""></p><p>矩阵分解用到的用户行为数据分为显式数据和隐式数据两种。显式数据是指用户对item的显式打分,比如用户对电影、商品的评分,通常有5分制和10分制。隐式数据是指用户对item的浏览、点击、购买、收藏、点赞、评论、分享等数据,其特点是用户没有显式地给item打分,用户对item的感兴趣程度都体现在他对item的浏览、点击、购买、收藏、点赞、评论、分享等行为的强度上。我们当前主要是隐式数据。</p><p>目标函数通过最小化预测评分和实际评分ruirui 之间的残差平方和,来学习所有用户向量和物品向量</p><h4>显示矩阵目标函数</h4><p><img src="/img/remote/1460000039918748" alt="" title=""></p><h4>隐式矩阵目标函数</h4><p><img src="/img/remote/1460000039918749" alt="" title=""></p><p>求解方法:矩阵分解的方法也不止一种,有奇异值分解,梯度下降,交替最小二乘,这里简单列举一个交替最小二乘的例子。</p><p>ALS(交替最小二乘法):先固定X 优化Y ,然后固定Y 优化X ,这个过程不断重复,直到X 和Y 收敛为止。</p><p>这里举一个,显式矩阵中固定Y优化X的例子,另外固定X优化Y:</p><ul><li>目标函数被拆解为多个独立目标函数,每个用户对应一个目标函数,用户u的目标函数为:<br><img src="/img/remote/1460000039918750" alt="" title=""></li><li>该目标函数转换为矩阵形式。</li><li>对目标函数J关于Xu求梯度,并令梯度为零。</li></ul><h3>逻辑回归→POLY2→FM→FFM</h3><p>首先逻辑回归的想法很巧妙,把推荐系统的推荐问题看做了一个分类问题,为什么可以这样说呢?</p><p>逻辑回归可以通过sigmoid 函数,将输入特征向量x=(x1,x2......xn)x=(x1,x2......xn),映射到(0,1)区间,如下图所示:<br><img src="/img/remote/1460000039918751" alt="" title=""></p><p>逻辑回归有着很多优势:</p><ul><li>数学含义上的支撑,逻辑回归的假设是因变量y服从Bernoulli分布,非常符合我们对于CTR模型的认识,相比之下线性回归模型假设y服从高斯分布,这与我们所理解的CTR预估模型(二分类问题)并不一致。</li><li>可解释性强,逻辑回归的简单数学形式也非常符合人类对 预估过程的直觉认知</li><li>工程化简单,具有易于并行化,训练简单,训练开销小的特点</li></ul><p>但也有一些缺点:</p><ul><li>表达能力不强,无法进行特征交叉、特征筛选等操作,所以会造成一些信息的损失</li></ul><p>正是由于这个原因,后面出现了POLY2模型,FM模型,FFM模型,接下来我一起说明:</p><h4>POLY2模型——特征的“暴力”组合</h4><p>该模型对所有特征进行了两两交叉(特征 Xj1 和 Xjz),并对所有的 特征组合赋予权重 Wh(j1,j2),本质上还是一个线性模型:</p><p><img src="/img/remote/1460000039918752" alt="" title=""></p><h4>FM——隐向量的特征交叉</h4><p>FM与POLY2的主要区别是用两个向量的内积(Wj1,Wj2) 取代了单一的权重系数Wh(j1,j2)。</p><ul><li>FM为每个特征学习了一个隐权重向量,特征交叉时,使用两个特征隐向量的内积作为交叉特征的权重。</li><li>引入隐向量的做法,与矩阵分解的思想异曲同工,是从单纯用户-物品扩展到了所有特征上。</li><li>把POLY2模型 n^2的权重参数减少到了nk。</li></ul><p><img src="/img/remote/1460000039918753" alt="" title=""></p><p>FM这样做的优势在于,隐向量的引人使 FM 能更好地解决数据稀疏性的问题,泛化能力大大提高。在工程方面, FM 同样可以用梯度下降法进行学习,使其不失实时性和灵活性 。</p><h4>FFM——特征域</h4><p><img src="/img/remote/1460000039918754" alt="" title=""></p><p>FFM 与 FM 的区别在于隐向量由原来的 Wj1 变成了 Wj1,f2,这意味着每个特征对应的不是唯一一个隐向量,而是一组隐向量,当特征 xj1 与特征 xj2 进行交叉时,xj1 会从 xj1 这一组隐向量中挑出与特征xj2的域f2对应的隐向量 Wj1,f2 进行交叉,同理, xj2 也会用与 xj1的域f1对应的隐向量进行交叉。</p><h4>模型演化的形象化表示</h4><h5>POLY2模型</h5><p><img src="/img/remote/1460000039918755" alt="" title=""></p><h5>FM模型</h5><p><img src="/img/remote/1460000039918756" alt="" title=""></p><h5>FFM模型</h5><p><img src="/img/remote/1460000039918757" alt="" title=""></p><h3>传统机器学习算法比较</h3><p><img src="/img/remote/1460000039918758" alt="" title=""></p><h2>大厂如何玩转推荐系统</h2><h3>大厂实践比较</h3><p>这里选取了几个比较典型的推荐系统实现,他们分别属于几种推荐系统的典型场景</p><p><img src="/img/remote/1460000039918759" alt="" title=""></p><h3>深度学习算法比较</h3><p>针对几个大厂部分采用了一些深度学习的模型,这里也调研对比了深度学习模型的特点和优劣势</p><p><img src="/img/remote/1460000039918760" alt="" title=""></p><h2>云课堂的个性化推荐</h2><h3>特征工程</h3><p>主要选用了用户行为数据,用户行为数据在推荐系统中有显性反馈行为和隐性反馈行为两种,在云课堂场景下,用户的评分属于显性行为,用户的购课,学习,做笔记等都属于隐性行为。对于这些行为,我们根据业务重要程度,都给出了初始分数,生成了用户-课程的初始评分矩阵</p><p><img src="/img/remote/1460000039918761" alt="" title=""></p><p>评分矩阵简单表示如下:</p><p><img src="/img/remote/1460000039918762" alt="" title=""></p><h3>算法选型</h3><p>在个性化推荐系统搭建初期,由于我们是从0到1开始构建,所以并没有选择在初期选择复杂的深度学习算法,以及构建丰富的用户画像,希望在初期快速构建一个MVP版本上线,后续逐步反思优化迭代</p><p>所以在算法选型上,我们从下面三种方案中进行评估选择</p><ul><li>基于标签匹配</li><li>基于用户/行为的协同过滤</li><li>基于矩阵分解的协同过滤</li></ul><p>那么我们是如何进行取舍的?</p><p>关于方案一,如果希望方案一取得较好的效果,关键点在于依赖标签体系的建设,只有标签体系足够完善,也就是说,推荐结果的好坏,是可预计的,强依赖于标签体系的建设的。</p><p>关于方案二,它的缺点在于处理稀疏矩阵的能力较弱,而云课堂中用户的学习行为并不能算是高频行为,同时头部效应明显,而我们希望的是通过个性化推荐系统,挖掘更多隐含的可能性,保留更多平台上更多平时没机会暴露的课程,显然基于近邻方式的协同过滤,不是一个很合适的选择。而基于矩阵分解的方法可以一定程度上增强稀疏矩阵的处理能力,同时引入隐向量,可以从用户行为中挖掘更多的可能性。</p><p>我们选用了基于ALS(交替最小二乘法)的矩阵分解模型作为第一个实践的算法,采用的是Spark MLlib提供的API。</p><p>在ALS模型的构建过程中,需要调整如下几个参数以取得最好的效果</p><p><img src="/img/remote/1460000039918763" alt="" title=""></p><p>对于上面几个参数,分别调整了几次参数,以MSE 和 RMSE 作为评价指标</p><p>均方误差( Mean Square Error , MSE)和均方根误差( Root Mean Square Error , RMSE) 经常被用来衡量回归模型的好坏。一般情况下, RMSE 能够很好地反映回归模型预测值与真实值的偏离程度 。 但在实际应用时,如果存在个别偏离程度非常大的离群点 , 那么即使离群点数量 非常少 , 也会让这两个指标变得很差 。</p><p><img src="/img/remote/1460000039918764" alt="" title=""></p><h3>工程落地</h3><p>一个可以落地的推荐系统,数据收集模块,ETL模块,特征工程模块,推荐算法模块,Web服务模块模块是必不可少的,首先来一个整体架构图:</p><p><img src="/img/remote/1460000039918765" alt="" title=""></p><p>接下来简单对几个模块的实现进行说明:</p><p><img src="/img/remote/1460000039918766" alt="" title=""></p><h2>参考文献</h2><p>1.《深度学习推荐系统》王喆</p><p>2.《推荐系统 原理与实践》 Charu C. Aggarwal</p><p><strong>-END-</strong></p>
网易有道 ASR 团队斩获 Interspeech 2021 算法竞赛两项冠军
https://segmentfault.com/a/1190000039873364
2021-04-22T11:11:31+08:00
2021-04-22T11:11:31+08:00
有道AI情报局
https://segmentfault.com/u/youdaotec
0
<p>在近期举行的Interspeech 2021会议上,主办方开展了“非母语儿童语音识别”的特别会议,并发布专项数据集用于算法评测竞赛,旨在推动非母语儿童语音识别技术的研究。<strong>此次竞赛共分为4个细分赛道,网易有道ASR团队斩获其中2项冠军与1项亚军</strong>。</p><blockquote>Interspeech是由国际语音通讯协会(International Speech Communication Association, ISCA)创办的顶级旗舰国际会议,作为全球最大的综合性语音信号处理领域的科技盛会,历届Interspeech会议都备受全球各地语音语言领域人士的广泛关注</blockquote><p>当前,自动语音识别(ASR)技术已经在很多场景中得到实际应用,但当应用于特定类型的人群(例如非母语人群和儿童等)的语音数据时,效果仍有较大的可提升空间。</p><p>非母语语音中,<strong>几种现象会大大降低传统ASR的性能</strong>,包括发音错误的单词、不合语法的发音等;儿童语音则由于生理差异(例如较短的声道长度)、认知差异(例如语言习得)和行为差异(例如习惯性低语)等因素,使得其更具挑战。目前也缺乏针对非母语和儿童语音的公开数据库。</p><p>虽然面临诸多挑战,但在实际应用中,很多语音数据均来自于非母语人士和儿童,例如语言学习等常见场景。研究针对这两类人群的ASR技术,具有非常大的意义和价值。</p><p>网易有道 ASR 团队凭借自身的AI技术能力,结合在线教育真实场景中的多年深耕实践,在此次“Interspeech 2021-非母语儿童语音识别”算法竞赛中取得<strong>2项冠军、1项亚军的优异成绩</strong>:在英文指定数据集和开放数据集两个赛道中,网易有道均以识别准确率更高获得冠军,并在德语指定数据集赛道中获得第二名。</p><p><img src="/img/remote/1460000039873366" alt="英文指定数据集赛道排名" title="英文指定数据集赛道排名"></p><p><img src="/img/remote/1460000039873367" alt="英文开放数据集赛道排名" title="英文开放数据集赛道排名"></p><p>网易有道ASR团队采用了<strong>速度扰动、音量扰动、基频扰动</strong>等多种数据增强方法以扩增数据量,减小不同语音音量差异,同时平衡不同年龄段儿童语音数据的基频变化。模型方面采用了<strong>传统 Hybrid-NN 模型和端到端 Transformer 模型</strong>。其中端到端模型还采用成人数据进行模型预训练,以帮助构建儿童语音识别单元以及训练声学模型。</p><p>目前,语音识别技术已经广泛应用于网易有道的众多产品中,如有道词典、有道精品课、有道少儿英语、有道词典笔等,开拓了明星语音、词典笔跟读绘本、语音答题等实际场景中的功能,给用户带来更加便捷、有趣的体验。</p><p><img src="/img/remote/1460000039873369" alt="有道词典笔跟读绘本功能" title="有道词典笔跟读绘本功能"></p><p>未来,网易有道将继续推进语音识别等AI技术的研究,结合在线教育真实场景与用户需求,不断用先进技术提升产品体验,为用户带来更大价值。</p><p><strong>- END -</strong></p>
在有道 | L同学:一位十五年有道人的成长故事
https://segmentfault.com/a/1190000039834372
2021-04-16T10:52:54+08:00
2021-04-16T10:52:54+08:00
有道AI情报局
https://segmentfault.com/u/youdaotec
0
<h3>写在前面</h3><blockquote><p>从开始运营有道技术团队之初,我们就一直想要启动《在有道》这个栏目,去聆听有道技术同学们的故事。我们觉得技术的背后,其实还是关于人,那些技术的思考与实践,也都源于人的创造和灵感。唯有理解了在有道工作与生活的人们,才能更好地理解有道。</p><p><strong>《在有道》系列的第一期,我们和网易有道技术总监L同学聊了聊他的故事。</strong></p><p><strong>作者/</strong> Knopfler<br><strong>编辑/</strong> Ein<br>访谈全文共计2089字<br>阅读需要6分钟</p></blockquote><h3>关于选择:没什么特别的理由</h3><p>2007年,L同学刚刚从北大计算机系硕士毕业,和每一位应届毕业生一样,都面临着从校园走入职场的关键一步。出众的背景让他有不少的选择,最终,他选择了加入网易有道。</p><p>彼时,虽然网易已经家喻户晓,但有道作为其中一个事业群,才刚刚成立一年。相比于成熟的事业群,有道那时更像是一家创业公司。谈起当初为什么选择有道时,L同学坦言,“其实没什么特别的理由,一位师兄推荐的,面试的时候聊的还不错,就加入了。”采访的同学并不甘心,想问出一些能够作为写作素材的理由,但L同学想了一会还是说,“当时真的没有想太多,就是感觉应该不会错。”或许他自己也不会预料到,<strong>这一次没有想太多的职业选择,也是他十五年来唯一的一次。</strong></p><p>刚加入有道时,L同学的岗位是研发工程师,在创业阶段的一线产品研发经历,让他以及同期加入的小伙伴们在之后的发展道路上有了坚实的基础。L同学说起这段经历时表示,“我加入比较早,算是伴随公司成长的。有道的风格偏创业公司,这一点直到今天依然如此。每一个项目都是宝贵的经验,都是对个人的成长。”</p><p>L同学在有道经历过很多项目,有非常成功的,也有一些失败的。他觉得只要整体上风险可控,那么失败并不可怕,很多事情是需要试错的,尤其是在业务开拓的早期。而且无论是失败还是成功的项目,都能从中学习到很多。</p><p><strong>有道持续地深入用户场景进行尝试,也最终得到了回报。</strong>如今,有道打造了一系列深受用户喜欢的口碑型大众学习工具产品,有道词典、有道精品课、有道云笔记等,都得到了用户的广泛认可。</p><p><img src="/img/bVcRh6Y" alt="" title=""></p><h3>关于成就感:做一款好用的产品</h3><p>直到2020年之前,L同学都在有道云笔记这个项目上,他从零开始参与了产品研发,一路成长为有道云笔记的负责人。这款产品不仅是他成长道路上的重要部分,也给他带来了很强的成就感。“经常有新认识的朋友,知道我在有道工作,会突然说,我在用有道云笔记这款产品,很好用。还是很有成就感的。”</p><p>其实,L同学的话也是很多有道人的心声。</p><p>能够参与到一款产品中去贡献自己的力量,看到它获得用户的认可,是最有成就感的事。如何去定义一款产品是精品呢?其实在用户的角度就是两个字——好用。但好用的背后,可能是工程师、产品经理们经过长期的思考、实践和修正才得出的结果。江湖上流传的<strong>“网易出品,必属精品”</strong>的说法,也是有道一直追求的目标。</p><p><img src="/img/bVcRh6Z" alt="" title=""></p><h3>关于成长:保持热爱,迎接挑战</h3><p>2020年,L同学再次投身到新的业务上——教育toB。<strong>这不仅对他个人而言是新的征程,对有道来说也是一次新的尝试。</strong>此时,L同学已经从一线的研发工程师成长为技术总监,所要关注的也不仅仅是代码了。对于现在的工作,他坦言压力不小。“当前要关注业务成功,这部分很复杂,因素特别多,有市场因素、产品因素、合作伙伴因素等等,甚至要关注行业发展、政策导向以及社会发展的大方向,比刚开始工作时只做研发,要压力大得多。”</p><p>L 同学还记得,在探索教育toB业务的初期,他们也碰过一些壁。“我们有一款硬件产品是放到学校的,让学生拍照提交自己的作业。我们起初认为应该是学生一个个去机器上提交作业,所以设计了很多让个体提交作业更快的办法。但实际学生的活动都是集体活动,是课代表提交班级所有人的作业,这时候刷卡其实没有优势——通过刷卡去选择某位同学的作业提交,远不如给个列表排序来的快,我们发现后很快修复这个问题。”</p><p>“如果不深入了解实际的用户场景,就可能会出现一些误判。这不是技术的问题,而是更综合的一个事情。”L同学如是说。</p><p>从单纯地做研发工作,到涉足技术管理,再到关注业务发展,L同学在充满创业氛围的有道,不断拓展自己的边界。正如他所说,每一段经历都有很多收获,但也充满挑战。<strong>成长之路或许就是如此,舒适区里的生活虽然安逸,却只能原地踏步。</strong>去尝试开拓边界,短期是有压力的,但却会带来更快的成长和更真实的满足感。</p><p><img src="/img/bVcRh64" alt="" title=""></p><h3>新的职务:把同事当作用户</h3><p>除了上面提到的工作之外,L同学现在还有一个新的职务:负责有道企业发展部的产品研发相关工作。</p><p>企业发展部的职责,是让有道内部的工作运转更加流畅和高效,需要进行一些内部系统和效率工具的选型、研发和维护工作。虽然是面向身边同事提供产品和服务,但L同学说,“其实这个工作同样需要有用户思维,设身处地从细节处思考,因为一个细节的不合理,也许会影响所有同事的效率和体验,这对公司而言是一种伤害。尤其有道的团队规模目前正在快速地扩大,这种细节就显得越来越重要。”</p><h3>尾声</h3><p>和L同学的聊天中,他提到最多的词是创业、成长和感激。在这个成长的过程中,随着工作内容的不断拓宽,他对于技术的理解也在变化。</p><p>有道和网易一样,把“和用户在一起”写到了价值观里。在这里,<strong>技术不仅仅是冰冷的去实现某个功能,虽然它需要逻辑性的思考,但最终是要服务于人的体验,所以技术也是有温度的。</strong>正是这样的温度支撑起一个又一个“必属精品”的产品,也让像L同学一样的有道人在这里不断成长和突破自己。</p><p><strong>这个温度,叫做热爱。</strong></p><p><strong>- END -</strong></p>
团队协作中,如何写出让同事赞不绝口的代码
https://segmentfault.com/a/1190000039790199
2021-04-08T10:25:01+08:00
2021-04-08T10:25:01+08:00
有道AI情报局
https://segmentfault.com/u/youdaotec
16
<blockquote><p>团队中的每个人都会用不同的视角来’审视‘你的”作品“,那么我们如何拿出一份像艺术品一样的项目代码,然后赢得得同事们的赞许呢?</p><p>作者/ 琼虎(安增平)</p><p>编辑/ hjy</p></blockquote><h2>00 前言</h2><p>在加入了拥有较高技术底蕴的有道精品课团队后,发现自己在前面的职业生涯中养成的一些‘作坊’习惯必须得到纠正。</p><p>在日常工作中,研发同学只在coding阶段中不需要别人关心自己的代码,其他需要将自己的产出展示给别人的场景变得十分常见。</p><p>简单举几个例子:</p><ul><li><p>feature准入后,同产品业务线的同事需要trans-review</p></li><li><p>mentor每个季度要Lint-review</p></li><li><p>测试二轮后要diff-review</p></li><li><p>......</p></li></ul><p>团队中的每个人都会用不同的视角来“审视”你的”作品“,<strong>那么我们如何拿出一份像艺术品一样的项目代码,然后获得同事们的称赞呢?</strong></p><p>保持在项目中做到以下几点,便可收获殿堂级的艺术代码。</p><p>以下几点是在接手销售转化系统及质检系统等几个项目后,针对自己的不a足和团队成员交流得出的结论。</p><h2>01 使用meaningful的变量命名</h2><p>在声明一个变量的时候,尽可能的将其作用和充当的角色注入其中:</p><p><img src="/img/remote/1460000039790430" alt="" title=""></p><p>声明一个函数,使用组合动词而非名词:</p><p><img src="/img/remote/1460000039790435" alt="" title=""></p><p>声明一个集合内部包含多项内容的时候,要记得使用复数形式:</p><p><img src="/img/remote/1460000039790432" alt="" title=""></p><p>在使用数学计算公式的时候尽量提前声明好常量,常量的注入有助于提升你在维护代码阶段的可读性:</p><p><img src="/img/remote/1460000039790433" alt="" title=""></p><p>在回调函数或者函数声明的形参中,尽量保持形参的语义化,避免后期维护过程中看到前面随意声明的i,j,k后,又要折返到原回调处进行查看,影响开发效率:</p><p><img src="/img/remote/1460000039790436" alt="" title=""></p><p>(同时在使用TS的过程中也尽量避免使用any类型,使用这种类型在codeReview过程中可能会被灵魂拷问)同时在声明boolean类型的时候要以is作为开头:</p><p><img src="/img/remote/1460000039790437" alt="" title=""></p><p><strong>做到以上这些,在codeReview中就可以保持一个自信的状态去接受同事们领导们的审阅,因为没有犯低级错误可以让查看你代码的人保持心情愉悦,同时这种心情可以对你产生正反馈。</strong></p><h2>02 每个函数只做一件事</h2><p>每个函数尽量保持其职责的单一性,不要出现一个非常强健的函数做了很多事情:</p><p><img src="/img/remote/1460000039790434" alt="" title=""></p><p>And这种单词本身就不是函数的一部分,他会导致添加过多的业务依赖或职责到当前的函数中,从长远的角度看这绝对是弊大于利的。</p><h2>03 让函数保持"纯洁"</h2><p>在函数外的任何东西,任何变量都不是他的业务,所以好的函数应该和函数外的任何变量保持好隔离。</p><p>下面这段代码可能只有刚入门的新手才会写出来,但是这种混乱的逻辑在业务复杂了之后,很可能会混入‘你’的代码中:</p><p><img src="/img/remote/1460000039790438" alt="" title=""></p><p>上面的例子可以改成下面这样:</p><p><img src="/img/remote/1460000039790431" alt="" title=""></p><p>当然在ES6的使用过程中上述问题普遍已经不存在了,但<strong>纯函数的思想</strong>需要时刻谨记。</p><h2>04 模块化业务逻辑</h2><p>当你在创建了一些函数之后,发现他们在当前的业务中做了一些比较类似的行为。例如,验证用户登陆的用户名和密码,那么我们最好可以将其归类为一个模块中。</p><p>这里我们可以称之为验证模块,而<strong>不是简单的使用一个util或者server将其集中起来</strong>就完事了:</p><p><img src="/img/remote/1460000039790439" alt="" title=""></p><h2>05 简化条件逻辑</h2><p>如果一个业务中出现了大量的if else这种内容,想必开发人员看到会十分头痛。</p><p>举个简单的例子:</p><p><img src="/img/remote/1460000039790440" alt="" title=""></p><p>仔细看下这里的else其实是不需要的,我们可以通过提前返回来remove掉:</p><p><img src="/img/remote/1460000039790441" alt="" title=""></p><h2>06 enrich u Error log</h2><p>当我们浏览某个App或网站时,经常会在点击某个按钮弹出“An Error Occurs”这种提示,这种提示很不友好,我们无法排查到底出现了什么原因,用户更是一头雾水,但是假如在出现这种错误的时候将描述信息填充的完整些,对用户或是技术支持都会有一个很棒的使用体验。</p><p>例如:当用户在表单中没有输入信息:</p><p><img src="/img/remote/1460000039790443" alt="" title=""></p><p>当用户此时网络出现了故障:</p><p><img src="/img/remote/1460000039790444" alt="" title=""></p><p>对开发者而言,一个详尽的提示能让你轻松定位到问题,节省了大量的时间:</p><p><img src="/img/remote/1460000039790442" alt="" title=""></p><p>包含但不限于这几种错误格式,还有showMessage等方法可以提供......</p><h2>07 利用好编辑器中的插件</h2><p>在VSCode下开发的同学,可以通过安装prettier来保持漂亮的代码。同时借助ESLint可以让你在开发时注重缩进、空格这些格式化的内容。</p><p>假如在开发过程中注入了TS,那么开启typescript-eslint会帮助你规范自己的类型定义,塑造一个风格严谨的代码style。</p><p>借助这些插件让我们的代码格式化时间大大降低,从而我们可以将更多的时间放在提升代码质量上。</p><h2>08 总结</h2><p>以上列举的几个例子较为简单。通过这些通俗易懂的例子,大家在工作中根据自己的理解举一反三的运用起来。那便是起到了作用。</p><p><strong>在开发中切勿眼高手低,在编码上做到一丝不苟,对我们技术的成长会有很大帮助。</strong></p><p><strong>唯有持之以恒,几十年如一日的训练才能见证技术圈的匠人诞生。共勉!</strong></p><p><strong>-END-</strong></p>
报名倒计时 | 有道技术沙龙,聊聊明星语音背后的故事
https://segmentfault.com/a/1190000039712223
2021-03-25T15:24:38+08:00
2021-03-25T15:24:38+08:00
有道AI情报局
https://segmentfault.com/u/youdaotec
0
<blockquote>自上周有道技术沙龙开始招募以来<p>已经收到了众多小伙伴的报名</p><p>给爱学习的同学们点个赞! </p><p>目前,有道技术沙龙<strong>仍在报名中</strong></p><p>欢迎来有道技术沙龙现场交流</p><p>到场观众赠送<strong>网易纪念款帆布包</strong></p><p>严选好物<strong>抽奖</strong>送不停!</p></blockquote><p><img src="/img/remote/1460000039712226" alt="IMG20210325_114102.png" title="IMG20210325_114102.png"><br>同时</p><p>多位无法到现场的同学咨询直播问题</p><p>有道技术团队决定联合 CSDN </p><p>开启<strong>线上直播</strong></p><p>全程直播技术分享环节!</p><p>直播间地址请点击 <a href="https://link.segmentfault.com/?enc=xxklf4HvVfyVZARs%2BlpmZA%3D%3D.iIGCDYssd1vnoIv%2FHUfO7ciUUfHWDp0UdGL%2BiNX%2FEITn39QkLbkoN4O1eB8C5GQM" rel="nofollow">https://live.csdn.net/room/wl...</a></p><p>有道技术沙龙等你来~</p><hr><p>彩蛋一枚:明星语音功能全新升级 有道词典首位女声明星上线</p><p><img src="/img/remote/1460000039712225" alt="IMG20210324_175104.png" title="IMG20210324_175104.png"></p><hr><p><img src="/img/remote/1460000039712227" alt="640.png" title="640.png"><br>-END-</p>
活动 | Apache Pulsar Meetup 欢迎报名
https://segmentfault.com/a/1190000039711565
2021-03-25T14:37:09+08:00
2021-03-25T14:37:09+08:00
有道AI情报局
https://segmentfault.com/u/youdaotec
0
<blockquote>由 StreamNative 发起<p>有道技术沙龙联合主办的</p><p>Apache Pulsar Meetup 北京站</p><p>走进网易北京研发中心!</p><p>Apache Pulsar PMC 成员、StreamNative CTO 翟佳</p><p>有道精品课实时数仓负责人李荣谦等多位业内实践者</p><p>将带来精彩的分享</p></blockquote><p><strong>时间</strong>:2021年3月27日13:30-17:30 </p><p><strong>地点</strong>:网易北京研发中心 B座1层报告厅</p><p>欢迎扫描图中二维码或点击<a href="https://link.segmentfault.com/?enc=u1haOqea%2BdlmWQZaRLDxFw%3D%3D.xRA04o18GOEgzms7p5IuvNrrD95M9zzMXBYf16%2FTK7b%2Bly%2BioCnSiVIpR87TKYi%2B" rel="nofollow">https://www.huodongxing.com/e...</a> 报名!</p><p><img src="/img/remote/1460000039711567" alt="IMG20210325_113910.png" title="IMG20210325_113910.png"></p><ul><li>END -</li></ul>
有道技术岗大揭秘!这么幸福的生活,真的是熬夜掉发Top1的职业吗?
https://segmentfault.com/a/1190000039711530
2021-03-25T14:35:47+08:00
2021-03-25T14:35:47+08:00
有道AI情报局
https://segmentfault.com/u/youdaotec
0
<p><img src="/img/remote/1460000039711532" alt="1.webp" title="1.webp"><br><img src="/img/remote/1460000039711534" alt="2.webp" title="2.webp"><br><img src="/img/remote/1460000039711540" alt="3.webp" title="3.webp"><br><img src="/img/remote/1460000039711539" alt="4.webp" title="4.webp"><br><img src="/img/remote/1460000039711541" alt="5.webp" title="5.webp"><br><img src="/img/remote/1460000039711533" alt="6.webp" title="6.webp"><br><img src="/img/remote/1460000039711535" alt="7.webp" title="7.webp"><br><img src="/img/remote/1460000039711537" alt="8.webp" title="8.webp"><br><img src="/img/remote/1460000039711536" alt="9.webp" title="9.webp"><br><img src="/img/remote/1460000039711538" alt="10.webp" title="10.webp"></p><h2>招聘信息</h2><p>点击 <a href="https://link.segmentfault.com/?enc=YsmDTKIHipoU6sPxsH2Aig%3D%3D.4gGxLXmb3wsojPNYczNTyvpp31U7%2FvJgAHutWMM4UBd6CwqXDV%2F0xiZ6A7j%2Bw%2Bqd" rel="nofollow">https://hr.163.com/product.ht...</a> 查看详情</p><h3>社招岗位</h3><ul><li>技术专家</li><li>JAVA工程师</li><li>直播技术支持</li><li>高级前端工程师</li><li>Android 工程师</li><li>前端开发工程师</li><li>安卓开发工程师</li><li>WEB前端工程师</li><li>嵌入式软件工程师</li><li>C++/Qt开发工程师</li><li>高级安卓开发工程师</li><li>Windows开发工程师</li><li>高级Android工程师</li><li>资深U3D开发工程师</li><li>高级前端开发工程师</li><li>高级测试开发工程师</li><li>web前端研发工程师</li><li>高级JAVA开发工程师</li><li>流媒体传输算法工程师</li><li>Java服务端开发工程师</li><li>流媒体服务端研发工程师</li><li>iOS开发工程师(词典产品部)</li><li>iOS开发工程师(有道精品课)</li><li>高级Windows C++ 开发工程师</li><li>Android Framework系统工程师</li></ul><h3>校招岗位</h3><ul><li>测试工程师</li><li>测试开发工程师</li><li>iOS开发工程师</li><li>Java开发工程师</li><li>C++开发工程师</li><li>前端开发工程师</li><li>NLP算法工程师</li><li>文本挖掘工程师</li><li>语音算法工程师</li><li>视频算法工程师</li><li>推荐算法工程师</li><li>运维开发工程师</li><li>大数据开发工程师</li><li>Android开发工程师</li><li>计算机视觉算法工程师</li><li>高性能计算工程师(C++)</li><li>技术支持(教学直播及场景优化)</li></ul><h3>实习生岗位</h3><ul><li>测试实习生</li><li>运维开发实习生</li><li>测试开发实习生</li><li>NLP算法实习生</li><li>语音算法实习生</li><li>C++开发实习生</li><li>算法工程实习生</li><li>iOS开发实习生</li><li>推荐算法实习生</li><li>Java开发实习生</li><li>前端开发实习生</li><li>围棋AI算法实习生</li><li>高性能计算实习生</li><li>嵌入式开发实习生</li><li>大数据开发实习生</li><li>音视频算法实习生</li><li>Android开发实习生</li><li>计算机视觉算法实习生</li></ul><p>-END-</p>
有道技术沙龙 | AI 语音交互技术在语言学习场景的实践
https://segmentfault.com/a/1190000039652528
2021-03-17T11:24:45+08:00
2021-03-17T11:24:45+08:00
有道AI情报局
https://segmentfault.com/u/youdaotec
0
<p><strong>经过数月的精心筹备</strong><br><strong>有道技术沙龙即将正式启动!</strong><br><strong>首先感谢:思否、将门-TechBeat、掘金技术社区合作伙伴的大力帮助</strong></p><p><img src="/img/remote/1460000039652531" alt="" title=""></p><p>首期我们将和大家聊聊<br><strong>语音交互技术</strong>的那些事儿<br>明星爱豆的声音是如何生成的?</p><p>一根笔如何教会小朋友读绘本?<br>选择题也可以用语音来回答吗?<br>答案尽在<strong> 有道技术沙龙 </strong>第一期!</p><p><img src="/img/remote/1460000039652533" alt="" title=""></p><p>除了干货满满的技术分享<br>当然不能少了<strong>福利</strong><br>吃好喝好必不可少<br>还有精美伴手礼<strong>到场即送</strong><br><strong>抽奖环节</strong>拼欧气抽严选好物<br>前30位到场的早鸟同学<br>还可兑换网易易间咖啡馆饮品一杯!</p><p><img src="/img/remote/1460000039652534" alt="" title=""></p><p>活动场地:网易易间咖啡厅</p><h4><strong>更重要的是:</strong></h4><p>作为首期有道技术沙龙的听众<br>有道将奉送<strong> 面试直通车 </strong>专属福利!<br>跳过重重难关<br>直接和终极 Boss 面对面!</p><h4><strong>活动信息:</strong></h4><p>时间:2021年3月27日 14:00-17:00</p><p>地点:网易北京研发中心-B1F 易间</p><p><img src="/img/remote/1460000039652532" alt="" title=""></p><p>【温馨提示】疫情防控不能松懈!到场需出示<strong>北京市健康码,以及报名手机号码接收的访客信息。</strong></p><p>欢迎扫描上方长图二维码或点击<a href="https://link.segmentfault.com/?enc=btQ2PFN2rH6E5kWW2d%2Fe0Q%3D%3D.WkAcTtuZJV7kuC6a%2Bu4Gr%2FLBQA2Xim9MyBy%2FR912jTA%3D" rel="nofollow">有道技术沙龙第一期-报名表</a>报名!</p><p>有道技术沙龙等你来!<br>如有疑问<br>欢迎添加有道小助手微信:ydtech01</p><hr><p><strong>相关岗位热招中!欢迎投递简历!</strong></p><ul><li>语音合成算法专家</li><li>语音识别&口语评测算法专家</li><li>声学前端信号处理算法专家</li><li>对话&语义理解算法专家</li><li>语音引擎技术专家</li><li>语音服务后台技术专家</li></ul><p>详情点击:<a href="https://link.segmentfault.com/?enc=loTiF5kgAn43XqfdFBAhzQ%3D%3D.MnE7HUkJMk68PPYf%2BKWjn76sLBlefMIuP2hL%2F%2FkQaFY%3D" rel="nofollow">网易招聘</a></p><p><img src="/img/remote/1460000039652530" alt="" title=""></p>
有道 Kubernetes 容器API监控系统设计和实践
https://segmentfault.com/a/1190000039390685
2021-03-11T10:53:21+08:00
2021-03-11T10:53:21+08:00
有道AI情报局
https://segmentfault.com/u/youdaotec
9
<p><img src="/img/remote/1460000039390695" alt="" title=""></p><blockquote>本期文章,我们将给大家分享有道容器服务API监控方案,这个方案同时具有轻量级和灵活性的特点,很好地体现了k8s集群化管理的优势,解决了静态配置的监控不满足容器服务监控的需求。并做了易用性和误报消减、可视化面板等一系列优化,目前已经超过80%的容器服务已经接入了该监控系统。 <p><strong>来源/</strong> 有道技术团队微信公众号<br><strong>作者/</strong> 郭超容 王伟静<br><strong>编辑/</strong> hjy</p></blockquote><h2>1.背景</h2><p>Kubernetes 已经成为事实上的编排平台的领导者、下一代分布式架构的代表,其在自动化部署、监控、扩展性、以及管理容器化的应用中已经体现出独特的优势。</p><p>在k8s容器相关的监控上, 我们主要做了几块工作,分别是基于prometheus的node、pod、k8s资源对象监控,容器服务API监控以及基于grafana的业务流量等指标监控。</p><p>在物理机时代,我们做了分级的接口功能监控——域名级别接口监控和机器级别监控,以便在某个机器出现问题时,我们就能快速发现问题。</p><p><img src="/img/remote/1460000039390691" alt="" title=""></p><p>上图中,左边是物理机时代对应的功能监控,包括域名级别接口监控和3台物理机器监控。右边是对应的k8s环境,一个service的流量会由k8s负载均衡到pod1,pod2,pod3中,我们分别需要添加的是service和各个pod的监控。</p><p>由于K8s中的一切资源都是动态的,随着服务版本升级,生成的都是全新的pod,并且pod的ip和原来是不一样的。</p><p>综上所述,传统的物理机API不能满足容器服务的监控需求,并且物理机功能监控需要手动运维管理,<strong>为此我们期望设计一套适配容器的接口功能监控系统,并且能够高度自动化管理监控信息,实现pod API自动监控。</strong></p><h2>2.技术选型</h2><p>为了满足以上需求,我们初期考虑了以下几个方案。</p><blockquote><strong>1.</strong> 手动维护各个service 和pod 监控到目前物理机使用的podmonitor开源监控系统。<p><strong>2.</strong> 重新制定一个包含k8s目录树结构的系统,在这个系统里面看到的所有信息都是最新的, 在这个系统里面,可以做我们的目录树中指定服务的发布、功能监控、测试演练等。</p><p><strong>3.</strong> 沿用podmonitor框架,支持动态获取k8s集群中最新的服务和pod信息,并更新到监控系统中。</p></blockquote><h4>+方案分析+</h4><p>针对方案一,考虑我们服务上线的频率比较高,并且k8s设计思想便是可随时自动用新生成的pod(环境)顶替原来不好用的pod,手动维护pod监控效率太低,该方案不可行。</p><p>第二个方案应该是比较系统的解决办法,但需要的工作量会比较大,这种思路基本全自己开发,不能很好的利用已有的功能监控系统,迁移成本大。</p><p><strong>于是我们选择了方案三</strong>,既能兼容我们物理机的接口功能监控方案,又能动态生成和维护pod监控。</p><h2>3.整体设计思路</h2><p>k8s监控包括以下几个部分:<br><img src="/img/remote/1460000039390687" alt="" title=""><br>其中API功能监控,是我们保证业务功能正确性的重要监控手段。</p><p>通常业务监控系统都会包含监控配置、数据存储、信息展示,告警这几个模块,我们的API功能监控系统也不例外。</p><p>我们沿用apimonitor框架功能,并结合了容器服务功能监控特点,和已有的告警体系,形成了我们容器API功能监控系统结构:<br><img src="/img/remote/1460000039390689" alt="" title=""></p><p>首先介绍下目前我们物理机使用的apimonitor监控:一个开源的框架<br><a href="https://link.segmentfault.com/?enc=dwvv4Fi3DXL0SQrszslAWA%3D%3D.GCfXn%2B2o0Kh%2FDpBuoFEptH%2Bl5L1YsvqcewiHMjREFfmwf7IeHkOGmuqbzG8nrCyV" rel="nofollow">https://gitee.com/ecar_team/a...</a></p><p>可以模拟探测http接口、http页面,通过请求耗时和响应结果来判断系统接口的可用性和正确性。支持单个API和多个API调用链的探测。</p><p>如下图所示,第一行监控里面监控的是图片翻译服务域名的地址,后边的是各台物理机的ip:端口。<br><img src="/img/remote/1460000039390690" alt="" title=""></p><p>点开每条监控<br><img src="/img/remote/1460000039390693" alt="" title=""></p><p><strong>我们沿用apimonitor框架的大部分功能,其中主要的适配和优化包括:</strong></p><blockquote><strong>1.</strong> 监控配置和存储部分:一是制定容器服务service级别监控命名规则:集群.项目.命名空间.服务;(和k8s集群目录树保持一致,方便根据service生成pod监控),二是根据service监控和k8s集群信息动态生成pod级别监控, <p><strong>2.</strong> 监控执行调度器部分不用改动</p><p><strong>3.</strong> 信息展示部分,增加了趋势图和错误汇总图表</p><p><strong>4.</strong> 告警部分,和其它告警使用统一告警组。</p></blockquote><h2>4.具体实践操作</h2><h3>4.1 添加service 级别API监控告警</h3><p>需要为待监控服务,配置一个固定的容service级别监控。</p><p>service级别监控命名规则:集群.项目.命名空间.服务</p><p>以词典查词服务为例,我们配置一条service级别的多API监控(也可以是单API监控)</p><p><strong>·</strong> 单API:一个服务只需要加一条case用</p><p><strong>·</strong> 多API:如果一个服务需要加多条功能case<br><img src="/img/remote/1460000039390694" alt="" title=""></p><p>其中“所属系统” 是服务所属的告警组,支持电话、短信、popo群、邮件等告警方式(和其它监控告警通用)</p><p>任务名称:取名规则,rancher中<strong>k8s集群名字.项目名字.命名空间名字.service名字</strong>(一共四段)</p><p><strong>告警消息的字段含义:</strong></p><pre><code>docker-dict:告警组,订阅后会收到告警消息
k8s-prod-th:集群
dict: 项目
dict:命名空间
data-server:workload名字
data-server-5b7d996f94-sfjwn:pod名字
{} :接口返回内容, 即:response.content
http://dockermonitor.xxx.youdao.com/monitorLog?guid=61bbe71eadf849558344ad57d843409c&name=k8s-prod-th.dict.dict.data-server.data-server-5b7d996f94-sfjwn : 告警详细链接</code></pre><h3>4.2 自动生成pod API监控</h3><p>自动生成下面三行监控任务:(第一行监控是按上面方法配置的容器service ip监控,后边三行是自动生成pod监控任务 )</p><p><img src="/img/remote/1460000039390696" alt="" title=""></p><p>监控service级别是单API,则自动生成的是单API,service级别是多API,则自动生成的是多API监控。</p><p>自动生成的pod级别监控,除了最后两行标红处(ip: port)和service级别不一样,其他都一样。<br><img src="/img/remote/1460000039390688" alt="" title=""></p><p><strong>实现pod自动生成的方法</strong></p><blockquote>1.给podmonitor(改框架是java语言编写的),增加一个java模块,用来同步k8s信息到podmonitor中。考虑到修改podmonitor中数据这个行为,本身是可以独立于框架的,可以不修改框架任何一行代码就能实现数据动态更新。<p>2.对比podmonitor数据库和k8s集群中的信息,不一致的数据,通过增删改查db,增加pod的监控。由于数据之间存在关联性,有些任务添加完没有例行运行,故采用了方法三。</p><p>3.对比podmonitor数据库和k8s集群中的信息,不一致的数据,通过调用podmonitor内部接口添加/删除一项监控,然后调接口enable /disable job等。<strong>按照可操作性难易, 我们选择了方法三</strong></p></blockquote><p><strong>针对于k8s集群中查到的一条pod信息:总共有三种情况:</strong></p><blockquote>1.对于表中存在要插入pod的监控信息记录,并且enable状态为1。则认为该pod的监控不需要改变<p>2.对于表中存在要插入pod的监控信息记录(删除操作并不会删除源数据信息),并且enable状态为0。则认为该pod的监控已被删除或者被停止。调用删除操作, 清空QRTZ (例行任务插件)表中的响应内容, 调用delete db操作清出监控信息相关表中的内容(使得监控记录不至于一直在增长)</p><p>3.对于表中不存在pod相关信息记录, 则需要新增加的一个pod。调用post 创建监控任务接口(根据service 监控配置), 并调用get请求设置接口为监控enabled状态。</p></blockquote><p>另外对于已经在物理机podmonitor中添加了监控的服务,提供了一个小脚本,用于导出物理机podmonitor域名级别监控到docker monitor监控中。</p><h2>5.难点和重点问题解决</h2><h3>5.1 误报消减</h3><p><strong>5.1.1上线告警抑制</strong></p><p>由于服务重启期间,会有removing状态和未ready状态的pod,在dockermonitor系统中存在记录,会引起误报。<br>我们的解决方法是提供一个通用脚本,根据k8s服务的存活检查时间,计算容器服务的发布更新时间,确定再自动开启服务监控的时机。实现在服务重启时间段,停止该服务的接口功能告警;存活检查时间过了之后,自动开启监控。<br>如下如所示,即Health Check中的Liveness Check检查时间。<br><img src="/img/remote/1460000039390692" alt="" title=""></p><p><strong>在我们上线发布平台上衔接了该告警抑制功能。</strong></p><p><strong>5.1.2弹性扩缩容告警抑制</strong></p><p>原来我们通过查询rancher的 API接口得到集群中全量信息,在我们服务越来越多之后, 查询一次全量信息需要的时间越来越长,基本需要5min左右。在这个过程中,存在docker-monitor和k8s集群中的信息不一致的情况。一开始试图通过按照业务分组,并行调用rancher接口得到业务k8s集群信息。时间从5min缩短到1min多钟。误报有一定的减少, 但从高峰期到低谷期时间段, 仍然会有若干pod在k8s集群中缩掉了, 但docker-monitor中仍有相应的告警。</p><p>在调研了一些方案之后,<strong>我们通过k8s增量事件(如pod增加、删除)的机制,拿到集群中最新的信息,pod的任何变更,3s钟之内就能拿到。</strong></p><p>通过es的查询接口,使用 filebeat-system索引的日志, 把pod带有关键字Releasing address using workloadID (更及时),或kube-system索引的日志: Deleted pod: xx delete 。</p><p>通过这个方案,已经基本没有误报。</p><h3>5.2策略优化</h3><blockquote>为了适配一些API允许一定的容错率,我们在apimonitor框架中增加了重试策略(单API和多API方式均增加该功能)<p>为了适配各类不同业务,允许设置自定义超时时间</p></blockquote><p><img src="/img/remote/1460000039390697" alt="" title=""></p><h3>5.3易用性</h3><p>增加复制等功能,打开一个已有的告警配置,修改后点击复制, 则可创建一个新的告警项<br>使用场景: 在多套环境(预发、灰度和全量)监控,以及从一个相似API接口微调得到新API监控</p><p><img src="/img/remote/1460000039390699" alt="" title=""></p><h3>5.4业务适配</h3><p>精品课对服务的容器化部署中使用了接口映射机制,使用自定义的监听端口来映射源端口,将service的监听端口作为服务的入口port供外部访问,如下图所示。当service的监听端口收到请求时,会将请求报文分发到pod的源端口,因此对pod级别的监控,需要找到pod的源端口。<br><img src="/img/remote/1460000039390698" alt="" title=""></p><p>我们分析了rancher提供的服务API文件后发现,在端口的配置信息中,port.containerPort为服务的监听端口,port.sourcePort为pod的监听端口,port.name包含port.containerPo<br>-rt和port.sourcePort的信息,由此找到了pod的源端口与service监听端口的关键联系,从而实现了对精品课服务接入本平台的支持。<br><img src="/img/remote/1460000039390700" alt="" title=""></p><h2>6.上线效果</h2><p>1.容器服务API监控统一,形成一定的规范,帮助快速发现和定位问题。<br>通过该容器API监控系统,拦截的典型线上问题有:</p><blockquote><strong>·</strong> xx上线误操作<br><strong>·</strong> 依赖服务xxxlib版本库问题<br><strong>·</strong> dns server解析问题<br><strong>·</strong> xxx服务OOM问题<br><strong>·</strong> xxx服务堆内存分配不足问题<br><strong>·</strong> xx线上压测问题<br><strong>·</strong> 多个业务服务日志写满磁盘问题<br><strong>·</strong> 各类功能不可用问题<br><strong>·</strong>…</blockquote><p>2.同时增加了API延时趋势图标方便评估服务性能:<br><img src="/img/remote/1460000039390701" alt="" title=""></p><p><strong>错误统计表方便排查问题:</strong><br><img src="/img/remote/1460000039390702" alt="" title=""></p><p><strong>结合我们k8s资源对象监控,和grafana的业务流量等指标监控,线上故障率显著减少,几个业务的容器服务0故障。</strong></p><h2>7.总结与展望</h2><h3>7.1总结</h3><p>本期文章中我们介绍了基于静态API监控和K8s集群化管理方案,设计了实时的自动容器API监控系统。</p><p>通过上述方案,我们能够在业务迁移容器后,很快地从物理机监控迁移到容器监控。统一的监控系统,使得我们线上服务问题暴露更及时、故障率也明显减少</p><h3>7.2展望</h3><blockquote>1.自动同步k8s服务健康检查到docker-monitor系统,保证每一个服务都有监控。<p>2.集成到容器监控大盘中,可以利用大盘中k8s资源目录树,更快查找指定服务,以及关联服务的grafana指标等监控。</p><p>3.自动恢复服务,比如在上线指定时间内,发生API监控告警,则自动回滚到上一版本,我们希望监控不仅能发现问题,还能解决问题。</p></blockquote><p><strong>监只是手段,控才是目标。</strong></p><h2>8.结语</h2><p>Docker技术将部署过程代码化和持续集成,能保持跨环境的一致性,在应用开发运维的世界中具有极大的吸引力。</p><p>而k8s做了docker的集群化管理技术,它从诞生时就自带的平台属性,以及良好的架构设计,使得基于K8s容器可以构建起一整套可以解决上述问题的“云原生”技术体系,也降低了我们做持续集成测试、发布、监控、故障演练等统一规划和平台的难度。目前有道业务服务基本都上线到容器,后续我们将陆续迁移基础服务,实现整体的容器化。</p><p><strong>我们也会不断积极拥抱开源,借鉴业界成功案例,寻找适合我们当前业务发展需要的理想选型。</strong></p><p><strong>-END-</strong></p>