SegmentFault 哈啰技术最新的文章
2024-03-25T14:28:59+08:00
https://segmentfault.com/feeds/blogs
https://creativecommons.org/licenses/by-nc-nd/4.0/
Node.js版本管理工具
https://segmentfault.com/a/1190000044743075
2024-03-25T14:28:59+08:00
2024-03-25T14:28:59+08:00
哈啰技术
https://segmentfault.com/u/hellotech
0
<h2>Node.js简介</h2><p>Node.js是一个开源的、跨平台的、用于服务端和网络应用的运行平台。它基于Google's V8引擎,并使用事件驱动、非阻塞I/O模型,使得其轻量且高效。Node.js的目标是使得JavaScript的开发范围扩展到Web开发之外,使开发者可以使用JavaScript为系统编写服务器端的软件,并轻松地构建高性能、实时的Web应用。Node.js包含了一系列内置模块,如文件系统访问、二进制数据处理、加密等,无需其他额外的库或工具就能进行服务器端开发。</p><p>对于前端开发者来说,Node.js具有重要的意义:</p><ul><li>统一的编程语言:前端开发通常使用JavaScript进行客户端编程,而Node.js允许开发者将同一种语言(JavaScript)用于服务器端编程。这种统一的编程语言带来了更高的开发效率和代码复用,使得前端开发人员能够更流畅地切换到服务器端开发,构建全栈应用。</li></ul><ul><li>前后端协作:Node.js作为服务器端运行时环境,可以提供RESTful API或其他形式的接口,使前端开发人员能够与后端开发人员更紧密地合作。前端开发人员可以利用Node.js构建自己的开发服务器,模拟后端接口,从而在没有实际的后端支持时进行开发和调试。</li></ul><ul><li>工具和生态系统:Node.js拥有庞大的生态系统,提供了丰富的第三方模块和工具,让前端开发更加便利。例如,npm是Node.js的包管理器,可以让开发者轻松地安装、管理和共享代码库。在前端开发中,可以使用npm安装各种用于构建、测试、部署等任务的工具,如Webpack、Babel、ESLint等。</li></ul><ul><li>异步编程模型:Node.js采用基于事件驱动和非阻塞I/O的异步编程模型,使得处理高并发请求成为可能。这种特性对于前端开发尤为重要,因为前端应用程序通常需要处理大量的异步操作,如网络请求、DOM事件等。Node.js的异步编程模型使得前端开发人员能够更好地理解和处理异步任务,提高应用程序的性能和响应能力。</li></ul><ul><li>服务器端渲染(SSR)和同构应用:Node.js可以用于实现服务器端渲染(Server-Side Rendering,SSR),将页面的初始渲染工作放在服务器端完成,提供更快的首次加载速度和更好的搜索引擎优化。此外,Node.js还支持同构应用开发,即一套代码同时运行在服务器端和客户端,提供更好的用户体验和更高的代码复用性。</li></ul><h2>Node.js版本管理工具</h2><p>Node.js版本管理工具的出现主要是因为在开发过程中,开发者可能需要在不同版本的Node.js之间切换,又或者是某些项目需要在特定版本的Node.js环境下运行。这时候,版本管理工具的作用就体现出来了,可以帮助开发者轻松切换Node.js的版本,甚至可以在不同项目之间维护不同版本的Node.js环境。</p><p>那为什么会产生这么多版本管理工具,主要有以下原因:</p><ul><li>支持新的Node.js版本:Node.js社区不断推出新的版本,引入新的特性和改进。更新的版本管理工具可以及时提供对新版本的支持,使开发者能够使用最新的Node.js功能和性能优化。</li></ul><ul><li>解决兼容性问题:Node.js在不同的操作系统和开发环境下可能存在兼容性问题。版本管理工具的更新可以修复这些问题,提供更好的跨平台支持和稳定性。例如,nvm可以在Windows和nix操作系统上使用,而n只支持nix系统。volta则提供了对JavaScript工具链(如node, npm, yarn等)的精细管理。</li></ul><ul><li>改进用户体验:版本管理工具的更新通常还包括用户界面和命令行工具的改进,提供更友好和易用的操作体验。这有助于开发者更高效地管理和切换Node.js版本。</li></ul><p>不同版本管理工具都具有一定的用户基础和一定的社区支持,下面为大家介绍几种不同的版本管理工具。</p><h2>NVM</h2><p>NVM的全称是Node Version Manager,是一个使用 bash 脚本编写的跨平台Node.js 版本管理器。它允许你在同一个机器上安装和切换多个 Node.js 版本。</p><h3>工作原理</h3><p>nvm的工作原理主要基于.bashrc文件(或.zshrc文件,取决于你的shell配置)。当你运行安装脚本时,它会在这些rc文件的末尾添加一些脚本。这些脚本将会在新shell启动时被运行,它会修改PATH环境变量包含nvm的目录。</p><p>当你使用nvm下载或使用特定版本的Node.js时,nvm会将这些版本的Node.js保存在其自己的目录中,并根据需要动态修改PATH环境变量。这样,你可以根据需要在不同的版本之间轻松切换。</p><h3>安装和简单使用</h3><ul><li>使用curl命令安装NVM:</li></ul><pre><code>curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.39.0/install.sh | bash</code></pre><ul><li>在.zshrc文件中添加如下配置:</li></ul><pre><code>export NVM_DIR="$HOME/.nvm"
[ -s "$NVM_DIR/nvm.sh" ] && \. "$NVM_DIR/nvm.sh" # This loads nvm
[ -s "$NVM_DIR/bash_completion" ] && \. "$NVM_DIR/bash_completion" # This loads nvm bash_completion</code></pre><ul><li>安装完成后,重新打开终端或运行以下命令使NVM生效:</li></ul><pre><code>source ~/.nvm/nvm.sh</code></pre><ul><li>使用以下命令安装Node.js版本:</li></ul><pre><code>nvm install <version></code></pre><ul><li>例如,要安装Node.js的最新稳定版本,可以运行:</li></ul><pre><code>nvm install stable</code></pre><ul><li>使用以下命令切换Node.js版本:</li></ul><pre><code>nvm use <version></code></pre><ul><li>例如,要切换到安装的某个版本,可以运行:</li></ul><pre><code>nvm use 14.17.0</code></pre><h2>N</h2><p>N是一个简单易用的Node.js版本管理工具,它提供了命令行工具来安装、切换和管理Node.js版本。</p><h3>工作原理</h3><p>N的原理是通过在系统中创建符号链接来实现版本切换。当你使用n安装新的Node.js版本时,它会将相应版本的二进制文件复制到一个特定的目录中,并创建一个名为node的符号链接指向该二进制文件。</p><p>当你使用n 命令安装新的Node.js版本时,n会检查所选版本的二进制文件是否已经存在,如果不存在,则会下载相应版本的二进制文件。然后,它将创建一个符号链接,将系统中的node命令指向所选版本的二进制文件。这样,系统中的node命令就会在切换版本时自动指向所选的Node.js版本。</p><h3>安装和简单使用</h3><ul><li>使用brew命令安装N:</li></ul><pre><code>brew install n</code></pre><ul><li>使用以下命令安装Node.js版本:</li></ul><pre><code>n <version></code></pre><ul><li>例如,要安装Node.js的最新稳定版本,可以运行:</li></ul><pre><code>n stable</code></pre><ul><li>使用以下命令切换Node.js版本:</li></ul><pre><code>n</code></pre><ul><li>按照提示选择要使用的版本。</li></ul><h2>Volta</h2><p>Volta是一个比较新兴的版本管理工具,旨在解决Node.js版本管理的复杂性和不一致性问题。它提供了一种项目级别的配置文件来管理Node.js版本,并提供了与其他工具和脚本的集成能力。</p><h3>工作原理</h3><p>Volta,作为一个JavaScript工具链管理器,其基本原理与其它版本管理系统(如 nvm,n,nodenv)类似,但在安装和运行时管理版本的策略上有所不同。</p><p>Volta的原理基于两个主要的概念:package.json文件和Volta的工具链。</p><ul><li>package.json文件:package.json是一个常见的用于描述和配置Node.js项目的文件。Volta利用了这个文件中的engines字段来确定项目所需的Node.js版本。当您在项目目录下执行命令时,Volta会检查该字段,并根据项目所需的版本来决定使用哪个版本的Node.js。</li></ul><ul><li>Volta的工具链:Volta维护了一个工具链,其中包含了多个Node.js版本的安装和管理。当您使用Volta安装Node.js时,它会将所选版本的二进制文件下载到工具链中的特定目录。这些二进制文件包括Node.js和npm。</li></ul><p>Volta的一个重要特性就是,它会在主目录的一个特殊的文件夹中保存下载了的所有版本。这就意味着,一旦某一个版本被下载,无论何时需要这个版本,Volta都可以立即提供,无需网络连接。</p><p>Volta的另一个重要特性是,它可以无缝切换各种版本的Node.js,npm,Yarn,以及其他的JavaScript CLI工具。这意味着,当你在项目中使用不同工具时,Volta能确保你不会遇到不兼容性问题。</p><h3>安装和简单使用</h3><ul><li>使用curl命令安装Volta:</li></ul><pre><code>curl https://get.volta.sh | bash</code></pre><ul><li>安装完成后,重新打开终端或运行以下命令使Volta生效:</li></ul><pre><code>source ~/.bash_profile</code></pre><ul><li>在项目目录中的package.json文件中添加engines字段,并指定所需的Node.js版本。例如:</li></ul><pre><code>"engines": {
"node": "14.17.0"
}</code></pre><ul><li>进入项目目录,Volta会自动检测并使用项目所需的Node.js版本。如果该版本未安装,Volta会提示您安装该版本。</li></ul><ul><li>安装特定版本的Node.js,使用volta install node@<版本号>,例如:</li></ul><pre><code>volta install node@14.17.0</code></pre><ul><li>要设置特定版本的Node.js为默认版本,你可以使用volta pin node@<版本号>,例如:</li></ul><pre><code>volta pin node@14.17.0</code></pre><ul><li>要查看当前项目所使用的node版本,运行volta list。</li></ul><pre><code>volta list</code></pre><h2>NVM、N、Volta的优劣对比</h2><h3>NVM (Node Version Manager)</h3><p>优点:</p><ul><li>成熟稳定:NVM 是最早出现并被广泛使用的 Node.js 版本管理工具之一,具有长时间的发展历史和大量的用户。</li></ul><ul><li>多平台支持:NVM 支持在多个操作系统上安装和管理 Node.js 版本,包括 macOS、Linux 和 Windows。</li></ul><ul><li>灵活的版本控制:NVM 允许同时安装和切换多个 Node.js 版本,使得开发者可以根据需要在不同的项目或环境中使用不同的版本。</li></ul><ul><li>社区支持:具有大的用户社区,有许多在线资源。</li></ul><p>缺点:</p><ul><li>配置复杂:NVM 的配置相对来说比较复杂,需要手动安装和设置环境变量来切换 Node.js 版本。</li></ul><ul><li>管理多个全局包:NVM 只能控制 Node.js 版本,对于全局安装的 npm 包没有直接管理能力。</li></ul><ul><li>由于在每个新的shell会话中需要重新运行安装,所以可能会影响性能。</li></ul><h3>N (Node.js version management)</h3><p>优点:</p><ul><li>简单易用:N 的配置和使用相对简单,通过命令行可以快速安装和切换 Node.js 版本。</li></ul><ul><li>快速安装:N 可以快速下载和安装 Node.js 版本,无需手动设置环境变量。</li></ul><ul><li>速度较快:通过更改系统链接来处理版本切换,因此切换速度快。</li></ul><p>缺点:</p><ul><li>仅限于 Node.js 版本管理:N 只关注 Node.js 的版本管理,对于其他工具和包管理器的集成支持相对较弱。</li></ul><ul><li>跨平台支持:在Windows上不那么容易使用(需要额外工具,如Cygwin)。</li></ul><ul><li>缺乏社区支援与深度维护。</li></ul><h3>Volta</h3><p>优点:</p><ul><li>项目级别配置:Volta 的主要特点是使用项目级别的配置文件来管理 Node.js 版本,使得每个项目可以指定所需的特定版本。</li></ul><ul><li>自动切换:Volta 可以自动检测并切换到项目所需的 Node.js 版本,无需手动操作。</li></ul><ul><li>工具链集成:Volta 可以与其他工具和脚本集成,确保使用与项目配置一致的 Node.js 版本。</li></ul><ul><li>语义兼容:兼容nvm和npm样式的语义版本控制命令。</li></ul><ul><li>版本缓存:在对特定的Node.js或npm版本进行第一次全局安装后,该版本会被缓存供以后使用。</li></ul><p>缺点:</p><ul><li>相对较新:Volta 是相对较新的工具,用户和社区支持相对较少,可能在某些方面缺乏成熟性和稳定性。</li></ul><ul><li>由于需要读取并修改PATH,可能会影响shell的启动速度。</li></ul><h3>共通点</h3><p>NVM,N和Volta都是用来管理Node.js版本的工具,它们有许多共同的特性和功能:</p><ul><li>版本切换:所有这三个工具都允许你在不同版本的Node.js之间随意切换。这使得你可以在需要的时候轻松地试用新版本,或者回退到老版本。</li></ul><ul><li>多版本并存:nvm,n和Volta都允许你在同一台计算机上安装和维护多个Node.js版本。这使得你可以为不同的项目使用不同版本的Node.js,而无需担心版本冲突的问题。</li></ul><ul><li>全局安装:这些工具都提供了将指定版本的Node.js设置为全局默认版本的功能。这意味着,除非特别指定,否则你的系统将使用这个版本来执行所有的Node.js命令。</li></ul><ul><li>跨平台:尽管有些工具在某些操作系统上的表现稍微优于其他工具,但总的来说,这三个工具都支持macOS、Linux和Windows。</li></ul><ul><li>开源:nvm,n和Volta都是开源的,这意味着你可以查看它们的代码,了解它们的工作原理,甚至为它们的开发做出贡献。</li></ul><h2>总结</h2><p>当你需要安装一个Node.js版本管理工具时:</p><ul><li>如果你需要一个成熟稳定、跨平台支持广泛的版本管理工具,并且对配置和管理多个全局包有需求,NVM 是一个不错的选择。</li></ul><ul><li>如果你希望一个简单易用、快速安装的工具,并且仅关注 Node.js 版本管理,N 是一个不错的选择。</li></ul><ul><li>如果你更倾向于项目级别的配置、自动切换和与其他工具的集成能力,并且对于相对新的工具有兴趣,可以尝试使用 Volta。</li></ul><p>总而言之,NVM、N、Volta都是很棒的 Node.js 版本管理器,可以帮助你更改,管理和更新 Node.js 的多个版本,还可以与新版本保持同步。</p><p>(本文作者:陈远翔)</p><p><img width="732" height="246" src="https://segmentfault.com/img/bVdajSo" alt="图片" title="图片"></p>
哈啰算法实时化2.0建设实践
https://segmentfault.com/a/1190000044726304
2024-03-19T14:23:17+08:00
2024-03-19T14:23:17+08:00
哈啰技术
https://segmentfault.com/u/hellotech
0
<h2>流式预测建设</h2><h3>为什么要建设流式预测</h3><p>其中一个主要原因是新的决策调用场景的接入,原有的决策调用场景主要是通过RPC接口调用触发的,而流式预测承接的场景主要由kafka等消息中间件来调用,这些场景都存在调用量大的特点,单个场景有上千、万QPS调用。流式预测也存在一些定时触发调用的场景,如供需预测场景,波峰波谷明显。流式预测可以将峰值QPS打平,保证实时性的前提下降低机器成本。二是机器成本,决策服务目前机器资源成本较大。三是接入配置繁琐,非流式预测接入方式需新建服务,通过代码开发方式接入,每次迭代都需进行排期上线的方式进行,较为繁琐。</p><h3>流式预测建设的难点</h3><p>流式预测建设主要有三部分难点,一是如何进行预测,流式预测场景数据源多为消息中间件,需要进行预测的量较大,采用现有决策服务调用所需要的成本较高。二是如何进行特征查询,模型所需的特征查询也存在查询量大的情况,且存储至HBase查询耗时较高,需要开启多并发进行查询。三是稳定性建设,包括预测任务数据延迟监控、任务失败数据重新消费、调用情况监控等能力的建设。</p><h3>Flink本地调用决策</h3><p><img src="/img/remote/1460000044726306" alt="图片" title="图片"></p><p>我们通过Flink接入决策SDK方式接入消息中间件数据,所有模型调用、规则调用都通过本地进行,脱离决策服务,只有元数据的加载、模型更新等存在依赖。关于Flink如何接入决策SDK, 我们通过Flink的UDF自定义算子的方式将决策SDK包装成一个通用的算子形式,再由Flink进行调用。</p><p><img src="/img/remote/1460000044726307" alt="图片" title="图片"></p><p>我们在实际场景接入中发现,Flink本地调用决策自定义算子的时候,相同的资源能够承受的QPS非常低,分析Fllink ui对应各个节点的执行代码,发现决策SDK存在重复调用的情况。</p><p><img src="/img/remote/1460000044726308" alt="图片" title="图片"></p><p>SQL逻辑可简化为上图,Kafka把数据发到决策SDK,通过json_to_string方式进行解析,给到输出源。实际上Flink在运行SQL时,会把SQL进行代码解析,生成AST语法树,最终实际调用不会复用决策SDK输出的结果。</p><p>我们查阅了具体的解决方案,一是修改底层Flink生成执行代码逻辑,判断用户udf是否可复用,生成可复用逻辑代码。这种方式部署时会影响其他任务,因此我们采用了第二种方案,添加一层透传专用的UDTF,牺牲少量的性能,解决重复调用问题。</p><h3>特征本地查询</h3><p><img src="/img/remote/1460000044726309" alt="图片" title="图片"></p><p>除了kafka消息中间件实时特征外,决策调用还需要离线的特征,这部分特征我们采用hive connector的方式缓存到本地内存中提供查询。这种方式存在一定问题,它的调用方式是全量拉取hive表数据,固定时间间隔更新,同步方式更新数据,会影响流式预测的实时性。因此我们对Flink自带的hive connector进行了修改,按照PT进行hive数据拉取,根据上游依赖方式进行数据更新,异步方式更新数据,保证预测的实时性。</p><p>我们还对底层存储类型进行了调整,原有的存储类型是全量缓存,flink为了支持大qps场景,会起多个subtask消费上游的kafka数据,每个subtask都会全量加载对应hive的数据,导致数据重复加载,内存占用非常高。我们采用Partitioned 方式做缓存优化,针对超大维表,按照Join Key进行Shuffle,每个进程上加载所需的维表数据,上游数据传输也需要按照对应方式传递。</p><p>我们也对存储格式进行了优化,原有Flink存储数据为自带RowData方式进行存储,改为通过byte数组方式进行存储,优化后相同10w条数据存储所需内存约之前三分之一。</p><h3>稳定性建设</h3><p><img src="/img/remote/1460000044726310" alt="图片" title="图片"></p><p>复用一站式AI平台现有的监控体系,把所有的请求通过埋点的方式写入到OpenTSDB和ES,OpenTSDB去监控调用情况和异常情况,提供给监控告警平台做监控告警。ES提供回放能力,出现异常时可以在决策日志查看里看到各节点的出入参情况。</p><h2>实时特征模版化建设</h2><h3>为什么要进行实时特征模版化</h3><p>主要有三个原因,一是实时特征统计逻辑相类似统计,我们统计已上线的实时特征发现,能够用模板化的方式进行配置的占到所有场景的76%,模版化方式能够降低用户学习配置成本,提高上线效率。二是节省工程同学人力,用户根据模板分类接入,减少咨询耗时,同时添加自动压测逻辑,减轻工程同学后续维护成本。三是底层逻辑优化,针对不同模板底层自动修改最终sql,优化Flink计算逻辑。</p><h3>实时特征配置流程优化</h3><p><img src="/img/remote/1460000044726311" alt="图片" title="图片"></p><p>原有的流程,用户需要做数据源定义、实时特征开发、存储源定义、特征上线,其中主要的耗时在实时特征开发。</p><p><img src="/img/remote/1460000044726312" alt="图片" title="图片"></p><p>优化后sql编写通过模板配置方式完成,减轻用户配置工作,压测动作通过自动化方式完成,减少工程同学运维动作。</p><h3>实时特征模板化的底层优化</h3><p><img src="/img/remote/1460000044726313" alt="图片" title="图片"></p><p>以统计商品当天曝光次数为例,我们通过设置sql模版,添加提前触发参数,将原有的每来一次数据聚合写入的逻辑调整为按照时间间隔定时统计写入,减轻底层数据库写入压力。</p><p><img src="/img/remote/1460000044726314" alt="图片" title="图片"></p><p>二是流式聚合优化,以统计当天唯一用户登录数为例,进行count distinct往往存在数据倾斜,热点的问题,即使添加资源,调整并发也无法缓解从而造成数据消费积压。通过对这些场景模板化设定,采用拆分distinct聚合来消除数据倾斜的问题,减少资源浪费。</p><h2>接入场景介绍</h2><p><img src="/img/remote/1460000044726315" alt="图片" title="图片"></p><p>这里以供需预测场景为例,原有的供需预测采用离线预测T+1站点未来24小时的预测值提供给调度引擎进行调度任务生成,由于站点的流入流出变动较大,常常出现负收益的调度任务,所以希望通过在线预测的方式来对站点净流出数进行预测更新。</p><p>(本文作者:吕盛泽)</p><p><img width="732" height="246" src="https://segmentfault.com/img/bVdajSo" alt="图片" title="图片"></p>
探索特征衍生:提高建模效果的秘诀
https://segmentfault.com/a/1190000044705322
2024-03-12T15:45:55+08:00
2024-03-12T15:45:55+08:00
哈啰技术
https://segmentfault.com/u/hellotech
0
<h2>特征衍生基本概念</h2><p>特征衍生主要指的是通过既有数据进行新特征的创建。总体来说,特征衍生有两类方法,其一是通过深入的数据背景和业务背景分析,进行人工字段合成,这种方法创建的字段往往具有较强的业务背景与可解释性,同时也会更加精准、有效的提升模型效果,但缺点是效率较慢,需要人工进行分析和筛选,称为手工特征衍生。其二则是抛开业务背景,直接通过一些简单暴力的工程化手段批量创建特征,然后从海量特征池中挑选有用的特征带入进行建模,这种方法简单高效,但存在衍生字段过多,有效特征没有衍生的问题,称为批量特征衍生。</p><p>特征衍生的相关方法更像是人们在长期实践过程中总结出来的方法论,这些方法切实有效,但没有一套能够完整统一的理论体系来“框住”这些方法。此外由于模型场景的复杂多变,特征衍生需要结合综合数据体量、数据规律、现有算力等因素进行考虑,所以这边主要介绍特征衍生的一些方法。</p><h3>特征衍生的本质</h3><p>所谓特征衍生,其本质指的是对既有数据信息的重新排布,手工特征衍生会先从思路出发、再分析数据集当前的业务背景或数据分布规律、最后再进行特征衍生。批量特征衍生优先考虑从方法出发,直接考虑单个列、不同的列之间有哪些可以衍生出新特征的方法,然后尽可能去衍生出更多的特征。</p><p><img src="/img/remote/1460000044705324" alt="图片" title="图片"></p><h3>特征衍生方法汇总</h3><p>批量特征创建的会先考虑如何把特征“做多”,然后再考虑如何把特征“做精”。总的来说,批量特征衍生有如下方法划分:单变量特征衍生、双变量特征衍生、关键特征衍生和多变量特征衍生。<br><img src="/img/remote/1460000044705325" alt="图片" title="图片"></p><h3>特征衍生准则</h3><p>通过上述特征衍生方式,只要将这些方法稍作组合,就可以衍生出无限个特征,但因为算力有限、时间有限,我们不可能进行无止尽的尝试。因此,在实际模型训练过程中,也并非无节制的朝向无限特征的方向进行特征衍生,往往我们需要有些判断,即哪些情况下朝什么方向进行特征衍生是最有效的。</p><h2>单变量特征衍生</h2><h3>数据重编码特征衍生</h3><p>连续变量数据重编码方法</p><ul><li>归一化:0-1归一化、Z-Score标准化</li><li>离散化:等距分箱/等频分箱/聚类分箱</li></ul><p>离散变量数据重编码方法</p><ul><li>字典编码</li><li>one-hot编码</li></ul><p>Embedding编码<br>如何需要对id这种多类型的特征进行编码时,采用one-hot、字典编码会存在极其稀疏的特征矩阵,不利于后续训练处理,针对这类特征可以采用embedding方式进行编码,能够用低维向量对物体进行编码还能保留其含义。</p><p>graph Embedding<br>将id特征采用grap构建,如图b所示,在图b中使用随机游走算法生成一系列的id特征序列,然后运用skip-gram算法生成对应的Embedding的向量表征形式。</p><p><img src="/img/remote/1460000044705326" alt="图片" title="图片"></p><h3>高阶多项式特征衍生</h3><p>对于单独的变量来说,除了可以通过重编码进行特征衍生外,还可以通过多项式进行特征衍生,即创建一些自身数据的二次方、三次方数据等。</p><p><img src="/img/remote/1460000044705327" alt="图片" title="图片"></p><h2>双变量特征衍生</h2><p>在大多数情况下,多个变量的交叉组合往往都比单变量特征衍生更有价值。一般来说,双变量特征衍生是目前常见特征衍生方法中最常见、同样也是效果最好的一类方法,而多变量特征衍生,除了四则运算(尤其以加法居多)的组合方法外,其他衍生方法随着组合的字段增加往往会伴随非常严重的信息衰减,因此该类方法除特定场合外一般不会优先考虑使用。</p><h3>四则运算特征衍生</h3><p>该过程非常简单,就是单纯的选取两列进行四则运算,基本过程如下:</p><p><img src="/img/remote/1460000044705328" alt="图片" title="图片"></p><p>该过程并不复杂,实际代码执行过程也只需要单独索引出两列然后进行四则运算即可。一般来说,四则运算特征衍生的使用场景较为固定,主要有以下三个:</p><ul><li>用于创建业务补充字段:例如用户当天总消费金额,我们甚至可以将其视作原始字段。</li><li>往往在特征衍生的所有工作结束后,我们会就这一系列衍生出来的新字段进行四则运算特征衍生,作为数据信息的一种补充。</li><li>在某些极为特殊的字段创建过程中使用,例如竞赛中常用的黄金组合特征、流量平滑特征等,需要使用四则运算进行特征衍生。</li></ul><h3>交叉组合特征衍生</h3><p>交叉组合特征衍生,指的是不同分类变量不同取值水平之间进行交叉组合。交叉组合后衍生的特征个数是参数交叉组合的特征的取值水平之积。</p><p><img src="/img/remote/1460000044705329" alt="图片" title="图片"></p><p>在使用时需注意越多的分类特征进行交叉组合、或者参与交叉组合的特征本身分类水平更多,衍生的特征数量也将指数级上涨,无论如何进行衍生,首先我们需要对衍生后的特征规模有基本判断。</p><h3>分组统计特征衍生</h3><p>分组统计就是A特征根据B特征的不同取值进行分组统计,统计量可以是均值、方差等针对连续变量的统计指标,也可以是众数、分位数等针对离散变量的统计指标,例如我们可以计算不同时间段用户的平均打车金额、金额最大值、最小值等。</p><p><img src="/img/remote/1460000044705330" alt="图片" title="图片"></p><p>分组统计的注意点:</p><ul><li>分组统计的字段可以是离散变量也可以为连续变量,进行聚合的字段最好选择离散变量,且最好取值较多。</li><li>分组统计也不局限于单表,可以通过多表连接后,再进行统计汇总。</li><li>分组统计完之后,可以将原始特征与衍生特征再进行一次四则运算特征衍生,例如当前打车金额减去平均打车金额,比较当前订单与当前时段平均订单金额差异。</li></ul><p><img src="/img/remote/1460000044705331" alt="图片" title="图片"></p><h3>多项式特征衍生</h3><p>双变量的多项式特征衍生与单变量的多项式特征衍生类似,增加了交叉项的计算,如下图所示:</p><p><img src="/img/remote/1460000044705332" alt="图片" title="图片"></p><p>多项式特征衍生的注意点:</p><ul><li>双变量多项式衍生只适用于两个连续变量之间,一个连续变量一个离散变量或者两个离散变量进行多项式衍生意义不大。</li><li>一般来说伴随着多项式阶数的增加,各列数值也会呈现指数级递增(或递减),因此往往我们只会衍生3阶左右,极少数情况会衍生5-10阶。需要配合一些手段来消除数值绝对值爆炸或者衰减所造成的影响,例如对数据进行归一化处理等;</li></ul><h3>统计演变特征</h3><p>根据衍生特征再进行进一步的特征衍生,这也是为何会出现无限特征的根本原因之一。不过在大多数情况下,二阶或者是更高阶的特征衍生往往伴随着严重的信息衰减,且计算过程往往需要消耗巨大的计算量。因此,高阶衍生往往性价比较低,除非特殊情况,否则并不建议在广泛特征基础上进行大量高阶特征衍生的尝试。</p><h4>原始特征与分组汇总特征交叉衍生</h4><p>最常用的特征衍生方法就是利用原始统计字段和分组统计衍生特征进行交叉衍生,例如之前分组统计得到的不同时间段打车金额,依此为依据,可以进一步构建下列统计演变特征:</p><ul><li>流量平滑特征<br>该特征通过原始统计字段除以分组汇总均值后的特征计算而来,因为是进行除法运算,为了避免分母为零的情况,我们可以在分母位上加上一个很小的数,具体计算过程如下:</li></ul><pre><code>df['cost'] / (df['mean'] + 1e-5)</code></pre><ul><li>黄金组合特征<br>所谓黄金组合特征,就是简单的利用原始特征减去mean计算得出:</li></ul><pre><code>df['cost'] - df['mean']</code></pre><ul><li>组内归一化特征<br>内归一化特征,指的是用cost减去mean,再除以std,其计算过程非常类似于归一化过程,即某列数据减去该列的均值再除以该列的标准差,这也是组内归一化名称的由来。具体计算过程如下:</li></ul><pre><code>(df['cost'] - df['mean']) / (np.sqrt(df['var']) + 1e-5)</code></pre><h4>分组汇总特征彼此交叉衍生</h4><p>基于分组汇总统计后的信息再次进行交叉衍生得到的新特征,这类特征往往具有较强的统计背景,能够更好的衡量原始特征的基本分布情况。</p><ul><li>Gap特征<br>Gap特征通过分组汇总后的上四分位数-下四分位数计算得出。</li></ul><pre><code>def q1(x):
"""
下四分位数
"""
return x.quantile(0.25)
def q2(x):
"""
上四分位数
"""
return x.quantile(0.75)
d1 = pd.DataFrame({'x1':[3, 2, 4, 4, 2, 2], 'x2':[0, 1, 1, 0, 0, 0]})
aggs = {'x1': [q1, q2]}
d2 = d1.groupby('x2').agg(aggs).reset_index()</code></pre><p><img src="/img/remote/1460000044705333" alt="图片" title="图片"></p><ul><li>数据倾斜<br>通过中位数和均值的比较来计算组内的数据倾斜情况:当均值大于中位数时,数据呈现正倾斜,均值小于中位数时,数据负倾斜。当然衡量倾斜的方法有两种,其一是计算差值,其二则是计算比值。</li><li>变异系数<br>变异系数是通过分组统计的标准差除以均值,变异系数计算的是离中趋势,变异系数越大、说明数据离散程度越高,相关计算过程如下:</li></ul><pre><code>np.sqrt(df['var']) / (df['mean'] + 1e-10)</code></pre><h2>多变量特征衍生</h2><h3>多变量的交叉组合特征衍生</h3><p>多变量的交叉组合和双变量的交叉组合类似,基本过程如下:</p><p><img src="/img/remote/1460000044705334" alt="图片" title="图片"></p><p>伴随着交叉组合特征数量的增加、以及每个特征取值水平增加,衍生出来的特征数量将呈指数级上涨趋势,例如3个包含两个分类水平的离散变量进行交叉组合时,将衍生出2^3=8个特征。特征矩阵会存在过于稀疏的问题,将极大程度影响后续建模过程。</p><p>所以只有在人工判断是极为重要的特征情况下,才会考虑对其进行三个甚至更多的特征进行交叉组合衍生。</p><h3>多变量的分组统计特征衍生</h3><p>在双变量分组特征衍生时,我们是选择某个特征为KeyCol(关键特征),然后以KeyCol的不同取值为作为分组依据,计算其他特征的统计量。而在多变量分组特征衍生的过程中,将考虑采用不同离散变量的交叉组合后的取值分组依据,再进行分组统计量的计算。</p><p><img src="/img/remote/1460000044705335" alt="图片" title="图片"></p><p>从直观的结果上来看,多变量分组统计特征衍生能够更细粒度的呈现数据集信息。但这种“细粒度”的呈现并不是越细粒度越好,在相同数据集下,分组越多、每一组的组内样本数量就越少,而在进行组内统计量计算时,如果组内样本数量太少,统计量往往就不具备代表性。</p><h3>多变量的多项式特征衍生</h3><p>与双变量的多项式特征衍生类似,具体如下所示:</p><p><img src="/img/remote/1460000044705336" alt="图片" title="图片"></p><h2>特征组合自动化能力</h2><h3>DFS</h3><p>DFS主要处理关系型数据,能够从中自动生成特征。本质上该算法遵循数据中基本字段的关系链路,然后沿该路径依次应用数学函数以创建最终特征。</p><p>目前FeatureTools采用该方式来进行自动化特征衍生,featureTools将对应表转化为entity的方式,定义entity之间的关联性,并通过DFS的方式来进行特征生成。</p><p>DFS将跨表之间的关联特征称为Ralated feature,将一对一关联的称为forward relation,一对多关联的称为backward relation,如下图如何将order表作为parent表,orders与user之间为forward relation,可直接进行关联;而order与 order products为backward relation,需选择聚合函数进行关联。</p><p><img src="/img/remote/1460000044705338" alt="图片" title="图片"></p><p>通过不同关联方式关联得到parent表后,再对parent表进行单表特征衍生。featuretools递归实现这些操作,也就是自底向上累积计算特征。伪代码如下:</p><p><img src="/img/remote/1460000044705339" alt="图片" title="图片"></p><p>FeatureTool特征构建都是通过人工设计的数据的转换和聚合函数实现的,生成的特征具有更好的可解释性,但是也存在特征维度过多的情况,需要做一些特征筛选的动作。</p><h3>Beam Search</h3><p>这个算法的主要思路是先生成一部分二阶组合特征,然后用效果好的二阶组合特征去衍生三阶组合特征,并非生成所有的三阶组合特征。相当于一种贪心的搜索方法。</p><p>AutoCross算法应用Beam Search来进行特征衍生:<br>AutoCross采用多粒度离散化方法来离散化同一个特征,比如特征“年龄”,我们按照年龄间隔为5的离散化一次,年龄间隔为10的离散化一次,年龄间隔为20的再离散化一次,同时生成多个不同的离散化特征,让模型自动去选择最适合它的特征,再采用beam search进行多阶特征衍生。</p><p>虽然采用beam search生产特征,但是对应得到的特征还是很多,AutoCross采用逐域对数几率回归(Field-wise LR)算法、连续小批量梯度下降等方式来进行特征筛选。</p><h2>关键特征衍生策略</h2><p>在大多数情况下,我们只需要合理使用上述双变量和多变量特征衍生方法,就能快速构建海量新特征,并且通过上述方法构建的特征池往往也都包含了绝大多数的潜在有效特征。对于某些特殊的特征,是无法通过上述自动化特征衍生方法进行更深入的有效信息挖掘,主要包括时序特征和文本特征。</p><h3>时序特征衍生方法</h3><h4>时序字段分析</h4><p>所谓的时序特征,其实就是指记录了时间的特征,例如交易发生时间、用户注册时间等,在很多场景下数据集的标签都会具有季节性波动规律,对应到数据集就是用户流失很有可能与季节、月份相关。时序特征衍生的主要方法有两种,详细信息衍生和基于自然周期划分衍生。</p><p><img src="/img/remote/1460000044705340" alt="图片" title="图片"></p><p>时序特征的衍生其本质上就是对特征进行了更多不同维度的分组,而对特征进行分组之所以能够帮助模型进行建模与训练,其根本原因也是因为有的时候,同一组内(或者是多组交叉)的用户会表现出相类似的特性(或者规律),从而能够让模型更快速的对标签进行更准确的预测。</p><ul><li>时间信息的更细粒度周期划分<br>假设time是以月为时间跨度进行的记录,则time的取值范围[0, 72]就表示过去6年的时间记录结果,对应的特征衍生方式如下:</li></ul><p><img src="/img/remote/1460000044705341" alt="图片" title="图片"></p><ul><li>时序字段的二阶特征衍生<br>当我们更细粒度的对时序特征进行划分后,接下来我们也可以进一步围绕衍生时序特征和原始特征进行双变量或多变量特征衍生,这也是所谓的时序字段的二阶特征衍生。</li></ul><h4>时序字段基本特征衍生方法</h4><p>针对Datetime类型的字段,提取对应的年、月、日、小时、分钟、秒等。</p><p><img src="/img/remote/1460000044705342" alt="图片" title="图片"></p><p>除了提取年月日等字段信息外,还有一些自然周期也会对结果预测有较大影响,如日期所在季度。这里需要注意的是,对于时序字段,往往我们会尽可能的对其进行自然周期的划分,然后在后续进行特征筛选时再对这些衍生字段进行筛选,而很多时候,除了季度,诸如全年的第几周、一周的第几天,甚至是日期是否在周末,具体事件的时间是在上午、下午还是在晚上等,都会对预测造成影响。</p><p><img src="/img/remote/1460000044705343" alt="图片" title="图片"></p><p>衍生是否为周末特征字段:</p><pre><code>(t['dayofweek'] > 5).astype(int) </code></pre><p>衍生当时时间所属周期:凌晨、上午、下午、晚上,以6个小时作为划分依据:</p><pre><code>(t['hour'] // 6).astype(int) </code></pre><p>关键时间点的时间差值衍生:<br>需设置关键时间点,再计算每条记录与关键时间点之间的时间差值,以天、月等维度来衡量。类似数据集记录的起始时间、结束时间、距今时间。</p><h3>文本特征衍生方法</h3><p>词向量转化<br>在NLP领域中,有一个极为普遍的建模分析场景——语言分析,例如分析一条评论的情感倾向,有文本数据如下:</p><p><img src="/img/remote/1460000044705344" alt="图片" title="图片"></p><p>最常见的方法就是词袋法,用一个词向量来表示一条文本。通过对每段文本进行不同单词的计数,然后用这些计数的结果来构成一个词向量,并借此来表示一个文本。词向量转化过程如下:</p><p><img src="/img/remote/1460000044705345" alt="图片" title="图片"></p><h2>自动化特征衍生步骤</h2><p>特征衍生通用流程分为四步,包括数据重编码、单变量特征衍生、交叉组合特征衍生和分组数据特征衍生。<br><img src="/img/remote/1460000044705346" alt="图片" title="图片"></p><p>(本文作者:吕盛泽)</p><p><img width="732" height="246" src="https://segmentfault.com/img/bVdajSo" alt="图片" title="图片"></p>
WebRTC拍摄在车主认证中的实现
https://segmentfault.com/a/1190000044680350
2024-03-04T16:03:01+08:00
2024-03-04T16:03:01+08:00
哈啰技术
https://segmentfault.com/u/hellotech
0
<h2>相关介绍</h2><h3>车主认证项目背景</h3><p>车主认证主体是以H5形式存在的,目前投放在多端,包括:哈啰App、车主App、货运车主App、支付宝小程序、微信小程序、H5外投页面,存在多端场景调用拍摄能力的需求。</p><p>存在问题:</p><ul><li>多平台适配<br>确保拍摄功能在各个平台上有良好的适配,包括哈啰App、车主App、货运车主App、支付宝小程序、微信小程序和H5外投页面。</li><li>小程序兼容性<br>对于支付宝小程序和微信小程序,要确保拍摄功能在小程序环境下能够正常调用。支付宝小程序目前借助小程序本身的拍摄能力,但是微信未提供视频拍摄方案。</li><li>外投页面兼容性<br>对于H5外投页面,可能会面临不同浏览器和设备的兼容性挑战。确保在各种浏览器中都能够正常加载和运行。</li></ul><h3>WebRTC简介</h3><p>WebRTC (Web Real-Time Communications) 是一项实时通讯技术,在不借助中间媒介的情况下,建立浏览器之间点对点(Peer-to-Peer)的连接,是一组用于在Web浏览器和移动应用程序中实现实时通信的开放标准和协议。它允许浏览器和应用程序之间通过简单的API实现音频、视频和数据的实时传输。</p><p>WebRTC 的典型应用场景包括实时视频通话、视频会议、屏幕共享、音视频录制等。</p><p>WebRTC主要包含以下三个核心模块:</p><ul><li>getUserMedia: 用于获取用户的音频和视频流。主要应用在视频和音频录制、视频通话和音频通话、在线会议和远程协作、人脸识别和图像处理等。</li><li>RTCPeerConnection: 用于建立点对点的连接,支持实时的音频和视频传输。主要应用在实时音视频通话、视频会议、屏幕共享等。</li><li>RTCDataChannel: 用于在两个对等体之间传输任意数据。主要应用在文件传输、实时游戏、即时消息、协同编辑、远程控制等。</li></ul><p>由于其 API 的多样,针对不同的场景,其他贡献者们做了有效封装,recordRTC 就是其中一个。 其基于WebRTC的 getUserMedia API 实现媒体设备访问, 并对 WebRTC提供的视频流函数进行了封装, 使开发者可以简单函数调用就能实现视频录制。</p><p>本方案的实现借助了WebRTC和RecordRTC的图像采集以及媒体数据流(getUserMedia)的控制能力,WebRTC的核心还包括实时传输、安全传输等等,有兴趣的同学可以自行了解。</p><h3>recordRTC简介</h3><p>recordRTC 是一个 JavaScript 库,提供了一些用于录制媒体流(如音频、视频)的功能。 基于 WebRTC 的 getUserMedia API,利用这一API,它可以获取用户的音频和视频流。以下是 recordRTC 利用 getUserMedia 提供的主要能力:</p><ul><li>获取摄像头和麦克风的访问权限: 通过 getUserMedia,recordRTC 可以请求用户授予对摄像头和麦克风的访问权限。用户可以选择允许或拒绝这些权限。</li><li>获取媒体流: getUserMedia 返回一个代表用户摄像头和麦克风的媒体流对象。这个媒体流包含实时的音频和视频数据。</li><li>媒体流的配置: 通过 getUserMedia 的配置参数,recordRTC 可以指定获取的媒体流的特性,例如选择前置或后置摄像头、指定视频分辨率、选择音频输入设备等。</li><li>实时预览: getUserMedia 允许在获取媒体流后进行实时的音视频预览。</li><li>动态更新媒体流: getUserMedia 提供了一些方法,在运行时可以动态更新媒体流的配置,例如切换摄像头、更改分辨率等。</li></ul><p>支持的浏览器:</p><p><img src="/img/remote/1460000044680352" alt="图片" title="图片"></p><p>常用参数:</p><ul><li>type: 接受 video or audio or canvas or gif</li><li>recorderType: 接受 MediaStreamRecorder or StereoAudioRecorder or WhammyRecorder or GifRecorder</li><li>timeSlice: 接受一个毫秒数; 用它来强制基于间隔的blob</li><li>ondataavailable: 将此函数与timeSlice一起传递以获取基于间隔的blob</li><li>bitsPerSecond: 每秒比特数; 适用于音频和视频的轨道</li><li>audioBitsPerSecond: 每秒比特数; 只适用于音频轨道</li><li>videoBitsPerSecond: 每秒比特数; 只适用于视频轨道</li><li>disableLogs: 接受 true or false; 用它禁用console的日志输出</li><li>frameInterval: 接受一个毫秒数</li><li>previewStream: 是 MultiStreamRecorder 的回调方法</li><li>video: 接受一个类似对象: {width: 320, height: 240}</li><li>canvas: 接受一个类似对象: {width: 320, height: 240}</li></ul><p>方法:</p><ul><li>startRecording(): 启动录制过程。调用此方法将开始捕获媒体流,并开始录制音频或视频。</li><li>stopRecording(callback): 停止录制过程。可以传递一个回调函数,用于在录制完成后处理录制的数据。</li><li>getBlob(): 获取录制数据的 Blob 对象。可以通过此方法获取录制的音频或视频数据。</li><li>pauseRecording(): 暂停录制。可以在录制过程中调用此方法以暂停录制。</li><li>resumeRecording(): 恢复录制。在暂停录制后,可以调用此方法以恢复录制过程。</li><li>clearRecordedData(): 清除录制的数据。</li><li>getDataURL(callback): 获取录制数据的 Data URL。通过回调函数获取录制的音频或视频数据的 Data URL。</li><li>setRecordingDuration(milliseconds): 设置录制的时长。可以通过此方法设置录制的最大时长,录制达到指定时长后会自动停止。</li></ul><h2>WebRTC拍摄具体实现</h2><h3>拍摄流程</h3><p><img src="/img/remote/1460000044680353" alt="图片" title="图片"></p><h3>具体实现</h3><h4>安装:</h4><p>安装 recordrtc 库,引入 RecordRTCPromisesHandler 类,用于处理WebRTC的视频录制。</p><pre><code>npm install recordrtc
import { RecordRTCPromisesHandler } from 'recordrtc';</code></pre><h4>使用:</h4><p>在车主认证项目中,将操作js拍摄化封装为一个 video-recorder 组件,在组件内部处理方法调用。</p><p>具体实现步骤大概分为3部分:</p><ul><li>初始化:获取拍摄设备和配置信息;</li><li>拍摄:使用 RecordRTCPromisesHandler 的实例化对象提供的方法;</li><li>上传:视频上传到阿里云OSS,并且进行回显。</li></ul><h5>初始化:</h5><p><img src="/img/remote/1460000044680354" alt="图片" title="图片"></p><p>因为目前手机存在多个后置摄像头场景,如果获取到的是广角或者桌面视角摄像头,则会有体验问题,所以在初始化时,将所有后置摄像头全部获取,可以让用户通过 Picker 进行选择。</p><p><img src="/img/remote/1460000044680355" alt="图片" title="图片"></p><p><img src="/img/remote/1460000044680356" alt="图片" title="图片"></p><p>getVideoConstraints方法,获取后置拍摄设备配置列表。</p><pre><code>async getVideoConstraints() {
let deviceId = '';
// 只有第一次时需要遍历镜头列表
if (!this.activeCamera) {
// 获取所有设备列表
const deviceList = await navigator.mediaDevices.enumerateDevices();
// 过滤出视频输入设备列表
const videoDeviceList = deviceList.filter((deviceInfo) => deviceInfo.kind === 'videoinput').reverse();
// 发送视频设备列表到父组件
this.$emit('output-list', videoDeviceList);
// 遍历视频输入设备列表
for (const device of videoDeviceList) {
// 获取特定设备的视频流
const stream = await navigator.mediaDevices.getUserMedia({
video: {
deviceId: device.deviceId,
},
audio: false,
});
// 检查摄像头是否为环境(后置)摄像头
const isEnvironment = stream.getVideoTracks()[0].getSettings().facingMode === 'environment';
// 停止获取的视频流上的所有轨道,释放资源
stream.getTracks().forEach((track) => {
track.stop();
});
// 如果是环境(后置)摄像头,则记录设备ID,并跳出循环
if (isEnvironment) {
deviceId = device.deviceId;
break;
}
}
}
// 设置视频约束
const result: MediaTrackConstraints = {
frameRate: { ideal: 6, max: 10 },
width: this.env.isAndroid ? { ideal: 960, min: 480, max: 960 } : { ideal: 480, min: 480, max: 960 },
height: this.env.isAndroid ? { ideal: 1280, min: 640, max: 1280 } : { ideal: 640, min: 640, max: 1280 },
facingMode: 'environment',
deviceId: this.activeCamera ? this.activeCamera.deviceId : deviceId,
aspectRatio: 3 / 4,
};
if (!deviceId && !this.activeCamera) {
delete result.deviceId;
}
// 返回视频约束
return result;
}</code></pre><h5>拍摄:</h5><p>点击录制按钮,通过调用 recorder 对象的 startRecording 方法来开启视频录制。</p><pre><code>async record() {
if (this.recorder) {
await this.recorder.startRecording();
this.isRecording = true;
}
}</code></pre><p>在开启录制后,倒计时5s,停止录制,调用 recorder 对象的 stopRecording 停止拍摄,通过 getBlob() 方法获取录制的 Blob对象,一定要在停止录制之后获取 Blob 对象,否则可能获取的Blob数据有问题。</p><pre><code>// 开始倒计时
startTimer() {
if (this.timerText > 1) {
this.recording = true;
this.timerText -= 1;
setTimeout(() => {
this.startTimer();
}, 1000);
} else {
this.resetTimer();
}
}
// 倒计时结束后重制
resetTimer() {
if (this.$refs.videoRecorder) {
this.$refs.videoRecorder.stop();
}
this.recording = false;
this.btnImgUrl = btnImgUrlMapper.DEFALUT;
this.timerText = 6;
}
// 停止拍摄并且上传文件
async stop() {
if (this.recorder) {
await this.recorder.stopRecording();
this.isRecording = false;
this.uploadFile();
}
}
// 获取视频流
async uploadFile() {
const video = await this.recorder.getBlob();
this.$emit('recorded', {
video,
});
}
</code></pre><h5>上传:</h5><p>视频上传是使用 aliyun 的 oss,在获取到 上传视频的 Blob 对象之后,上传到 aliyun 进行存储,通过返回的文件名 videoRes.name 获取视频的预览Url,跳转到Ocr识别页,进行Ocr识别。</p><p><img src="/img/remote/1460000044680357" alt="图片" title="图片"></p><p>(本文作者:佟健)</p><p><img width="732" height="246" src="https://segmentfault.com/img/bVdajSo" alt="图片" title="图片"></p>
Service Worker:离线应用与后台同步的解决方案
https://segmentfault.com/a/1190000044661431
2024-02-27T11:39:30+08:00
2024-02-27T11:39:30+08:00
哈啰技术
https://segmentfault.com/u/hellotech
0
<h2>前端常用缓存技术</h2><p>前端常用缓存技术一般分为http缓存和浏览器缓存。</p><h3>HTTP缓存</h3><h4>Expires</h4><p>HTTP1.0的内容,服务器使用Expires头来告诉Web客户端它可以使用当前副本,直到指定的时间为止;</p><h4>Cache-Control</h4><p>HTTP1.1引入了Cathe-Control,它使用max-age指定资源被缓存多久,主要是解决了Expires一个重大的缺陷,就是它设置的是一个固定的时间点,客户端时间和服务端时间可能有误差;</p><h4>Last-Modified / If-Modified-Since</h4><p>Last-Modified是服务器告诉浏览器该资源的最后修改时间,If-Modified-Since是请求头带上的,上次服务器给自己的该资源的最后修改时间。然后服务器拿去对比。<br>若Last-Modified大于If-Modified-Since,说明资源有被改动过,则响应整片资源内容,返回状态码200;<br>若Last-Modified小于或等于If-Modified-Since,说明资源无新修改,则响应HTTP 304,告知浏览器继续使用当前版本。</p><h4>Etag / If-None-Match</h4><p>Etag是服务器根据每个资源生成的唯一标识符,当文件内容被修改时标识符就会重新生成。服务器存储着文件的Etag字段,可以在与每次客户端传送If-none-match的字段进行比较。如果相等,则表示未修改,响应304;反之,则表示已修改,响应200状态码,返回数据。</p><h3>浏览器缓存</h3><h4>Storage</h4><p>简单的缓存方式有cookie,localStorage和sessionStorage,都是浏览器内置储存功能。</p><h4>mainfest</h4><p>html5引入的新标准,可以离线缓存静态文件。</p><h4>Service Worker</h4><h2>ServiceWorker 介绍</h2><h3>什么是ServiceWorker</h3><p><img src="/img/remote/1460000044661433" alt="图片" title="图片"></p><p>Service Worker本质上是充当web应用程序与浏览器之间的代理服务器,也可以在网络可用时作为浏览器和网络间的代理。它们的目的是创建有效的离线体验、拦截网络请求并根据网络是否可用采取适当的操作,以及更新服务器上的资产。它们还允许访问推送通知和后台同步 API。</p><h3>ServiceWorker特性</h3><ul><li>Service Worker本质上是一个Web Worker,它独立于Javascript主线程,因此它不能直接访问DOM,也不能直接访问window对象,但是可以访问navigator对象,也可以通过消息传递的方式(如postMessage)与Javascript主线程进行通信。</li><li>Service Worker独立于Javascript主线程,所以不会造成阻塞。它设计为完全异步,同步API(如XHR和localStorage不能在Service Worker中使用。</li><li>Service Worker是基于 HTTPS 的,因为Service Worker中涉及到请求拦截,所以必须使用HTTPS协议来保障安全。如果是本地调试的话,localhost是可以的。</li><li>Service Worker拥有独立的生命周期,与页面无关(关联页面未关闭时,它也可以退出,没有关联页面时,它也可以启动)。注册Service Worker后,浏览器会默默地在背后安装Service Worker。</li></ul><h3>ServiceWorker生命周期</h3><p><img src="/img/remote/1460000044661434" alt="图片" title="图片"></p><p>Service Worker 的生命周期可以分为6个阶段:解析(parsed)、安装(installing)、安装完成(installed)、激活(activating)、激活完成(activated)、闲置(redundant)。</p><h4>Parsed</h4><p>当我们第一次尝试注册 Service Worker 时,用户代理会解析脚本并获取入口点。如果解析成功(并且满足其他一些要求,例如 HTTPS),我们将可以访问 Service Worker 注册对象。其中包含有关 Service Worker 的状态及其作用域的信息。</p><pre><code>if ('serviceWorker' in navigator) {
navigator.serviceWorker.register('./sw.js')
.then(function(registration) {
console.log("Service Worker Registered", registration);
})
.catch(function(err) {
console.log("Service Worker Failed to Register", err);
})
}</code></pre><p>Service Worker注册成功并不意味着它已安装完毕或处于激活状态,而只是意味着脚本已成功解析,它与文档处于同一源上,且源为 HTTPS。注册完成后,服务 Worker 将进入下一个状态。</p><h4>Installing</h4><p>一旦 Service Worker 脚本被解析,用户代理就会尝试安装它,并进入安装状态。在 Service Worker 的registration对象中,我们可以在installing属性中检查此状态。</p><p>并且,在installing状态下,install事件会被触发,我们一般会在这个回调中处理缓存事件。</p><pre><code>navigator.serviceWorker.register('./sw.js').then(function(registration) {
if (registration.installing) {
// Service Worker is Installing
}
})</code></pre><pre><code>self.addEventListener('install', function(event) {
event.waitUntil(
caches.open(currentCacheName).then(function(cache) {
return cache.addAll(arrayOfFilesToCache);
})
);
});</code></pre><p>如果事件中有 event.waitUntil() 方法,其中的 Promise只有在resolve后,install事件才会成功。如果 Promise 被拒绝,install就会失败,Service Worker 就会变为redundant状态。</p><pre><code>self.addEventListener('install', function(event) {
event.waitUntil(
return Promise.reject(); // Failure
);
});</code></pre><h4>Installed / Waiting</h4><p>如果安装成功,Service Worker 的状态变为 installed (也叫 waiting )。处于这个状态时, Service Worker 是有效的但是是未激活的 worker,暂时没有控制页面的权力,需要等待从当前 worker 获得控制权。</p><p>我们可以在 registration 对象的 waiting 属性中检测到此状态。</p><pre><code>navigator.serviceWorker.register('./sw.js').then(function(registration) {
if (registration.waiting) {
// Service Worker is Waiting
}
})</code></pre><p>我们可以在这个时机去更新新版本或自动更新缓存。</p><h4>Activating</h4><p>在以下情况之一时,处于 Waiting 状态的 worker 的 Activating 状态会被触发:</p><ul><li>当前没有处于激活状态的 worker</li><li>self.skipWaiting() 在 sw.js 中被调用,直接跳过waiting阶段</li><li>用户导航离开当前页面,从而释放了前一个 active worker</li><li>经过了指定时间段,从而释放了前一个 active worker</li></ul><p>在当前状态下,activate事件会被触发,在这个回调中我们通常用于清除旧缓存。</p><pre><code>self.addEventListener('activate', function(event) {
event.waitUntil(
// Get all the cache names
caches.keys().then(function(cacheNames) {
return Promise.all(
// Get all the items that are stored under a different cache name than the current one
cacheNames.filter(function(cacheName) {
return cacheName != currentCacheName;
}).map(function(cacheName) {
// Delete the items
return caches.delete(cacheName);
})
); // end Promise.all()
}) // end caches.keys()
); // end event.waitUntil()
});</code></pre><p>同install事件,如果Promise被reject了,则activate事件失败,Service Worker变为redundant状态。</p><h4>Activated</h4><p>如果激活成功,Service Worker 状态会变成 active ,在这个状态下,Service Worker 是一个可以完全控制网页的激活 worker,我们可以在 registration 对象的 active 属性中检测到此状态。</p><pre><code>navigator.serviceWorker.register('./sw.js').then(function(registration) {
if (registration.active) {
// Service Worker is Active
}
})</code></pre><p>当 Service Worker 被成功激活后,即可处理绑定的 fetch 和 message 事件。</p><pre><code>self.addEventListener('fetch', function(event) {
// Do stuff with fetch events
});
self.addEventListener('message', function(event) {
// Do stuff with postMessages received from document
});</code></pre><h4>Redundant</h4><p>以下任一情况,Service Worker 都会变成 redundant。</p><ul><li>install失败</li><li>activate失败</li><li>有新的 Service Worker 将其替代成为现有的激活 worker</li></ul><h2>Service Worker 离线缓存</h2><p>Service Worker 最重要的功能之一,就是可以通过缓存静态资源来实现离线访问我们的页面。</p><p>Service Worker 的缓存基于 CacheStorage,它是一个 Promise 对象,我们可以通过 caches 来获取它。CacheStorage提供了一些方法,我们可以通过这些方法来对缓存进行操作。</p><pre><code>caches.open(currentCacheName).then(function (cache) {
/** 可以通过cache.put来添加缓存
* 它接收两个参数,第一个参数是Request对象或URL字符串,第二个参数是Response对象
*/
cache.put(new Request('/'), new Response('Hello World'));
/** 可以通过cache.addAll来添加缓存资源数组
* 它接收一个参数,这个参数可以是Request对象数组,也可以是URL字符串数组
*/
cache.addAll(['/'])
/** 可以通过cache.match来获取缓存
* 它接收一个参数,这个参数可以是Request对象,也可以是URL字符串
*/
cache.match('/').then(function (response) {
console.log(response);
});
/** 可以通过cache.delete来删除缓存
* 它接收一个参数,这个参数可以是Request对象,也可以是URL字符串
*/
cache.delete('/').then(function () {
console.log('删除成功');
});
/** 可以通过cache.keys来获取缓存的key
* 然后通过cache.delete来删除缓存
*/
cache.keys().then(function (keys) {
keys.forEach(function (key) {
cache.delete(key);
});
});
});</code></pre><h3>缓存资源</h3><p>我们在介绍生命周期的时候我们介绍了在installing状态下会调用install方法,通常我们会在install事件中缓存一些资源。</p><pre><code>self.addEventListener('install', function (event) {
event.waitUntil(
caches.open(currentCacheName).then(function (cache) {
return cache.addAll([
'/',
'/index.css',
'/axios.js',
'/index.html'
]);
})
);
});</code></pre><p>上面的代码中我们缓存了一些资源,所以我们可以在fetch事件中获取并返回刚刚缓存的资源。</p><pre><code>self.addEventListener('fetch', function (event) {
event.respondWith(
caches.match(event.request).then(function (response) {
if (response) {
return response;
}
return fetch(event.request);
})
);
});</code></pre><p>上面的代码中我们使用caches.match来匹配请求,如果匹配到了,那么就返回缓存的资源,如果没有匹配到,那么就从网络中获取资源。</p><h3>缓存更新</h3><p>在上面的步骤中,我们已经缓存了我们的资源,并且该资源并不会随着我们代码或者资源的更改而更新缓存。因此,我们可以通过版本号来控制更新。</p><p>介绍生命周期时,我们有了解到在activating状态下会触发activate回调,在该回调中我们可以清除旧缓存,然后在install事件中缓存新的资源。</p><pre><code>const version = '2.0';
const currentCache = 'my-cache' + version;
self.addEventListener('activate', function (event) {
event.waitUntil(
caches.keys().then(function (cacheNames) {
return Promise.all(
cacheNames.map(function (cacheName) {
if (cacheName !== currentCache) {
return caches.delete(cacheName);
}
})
);
})
);
});</code></pre><h3>卸载</h3><p>当我们的页面不再需要Service Worker的时候,可以通过在新版本里使用unregister进行卸载。</p><pre><code>if ('serviceWorker' in navigator) {
navigator.serviceWorker.ready.then(registration => {
registration.unregister();
});
}</code></pre><p>需要注意的是,Service Worker卸载并不会删掉我们之前缓存的资源,所以在卸载之前我们需要清除所有的缓存。</p><h3>缓存策略</h3><p>从上面的例子可以看出,Service Worker的缓存是通过Cache接口和fetch事件共同实现的。通过Cache接口和fetch事件可以实现多种缓存策略。</p><h4>仅缓存 (Cache only)</h4><p><img src="/img/remote/1460000044661435" alt="图片" title="图片"></p><p>适用于你认为属于该“版本”网站静态内容的任何资源,匹配的请求将只会进入缓存。</p><h4>仅网络 (Network only)</h4><p><img src="/img/remote/1460000044661436" alt="图片" title="图片"></p><p>与“仅缓存”相反,“仅限网络”是指请求通过 Service Worker 传递到网络,而无需与 Service Worker 缓存进行任何交互。</p><h4>缓存优先 (Cache first)</h4><p>该策略流程如下:</p><ul><li>请求到达缓存。如果资源位于缓存中,请从缓存中提供。</li><li>如果请求不在缓存中,请转到网络。</li><li>网络请求完成后,将其添加到缓存中,然后从网络返回响应。</li></ul><p><img src="/img/remote/1460000044661437" alt="图片" title="图片"></p><p>该策略适用于静态资源的缓存,它可以绕过 HTTP 缓存可能启动的服务器执行任何内容新鲜度检查,从而加快不可变资源的速度。</p><h4>网络优先 (Network first)</h4><p>该策略如下:</p><ul><li>先前往网络请求一个请求,然后将响应放入缓存中。</li><li>如果您日后处于离线状态,则会回退到缓存中该响应的最新版本。</li></ul><p><img src="/img/remote/1460000044661438" alt="图片" title="图片"></p><p>此策略非常适合 HTML 或 API 请求,当您想在线获取资源的最新版本,同时又希望离线可以访问到最新的可用版本。</p><h4>延迟验证 (Stale-while-revalidate)</h4><p><img src="/img/remote/1460000044661439" alt="图片" title="图片"></p><p>该机制与最后两种策略类似,但其过程优先考虑资源访问速度,同时还在后台保持更新。策略大致如下:</p><ul><li>在第一次请求获取资源时,从网络中提取资源,将其放入缓存中并返回网络响应。</li><li>对于后续请求,首先从缓存提供资源,然后“在后台”从网络重新请求该资源,并更新资源的缓存条目。</li><li>对于此后的请求,您将收到在上一步中从缓存中放置的最后一个网络提取的版本。</li></ul><h2>Service Worker 后台同步</h2><p>假设用户在我们的页面上操作了数据并提交,此时正好进入一个网络极差甚至断网的环境里,用户只能看着一直处于loading状态的页面,直到失去耐心关闭页面,这时请求就已经被中断了。</p><p>上面这种情况暴露了两个问题:</p><ul><li>普通页面会随着页面关闭而终止</li><li>网络极差或无网络情况下没用一种解决方案能够解决并维持当前请求以待有网时恢复请求</li></ul><p>后台同步是构建在 Service Worker 进程之上的另一个功能,它允许一次性或以一个时间间隔请求后台数据同步。我们可以充分利用这一功能规避以上问题。</p><h3>工作流程</h3><p><img src="/img/remote/1460000044661440" alt="图片" title="图片"></p><ul><li>在Service Worker中监听sync事件</li><li>在浏览器中发起后台同步sync</li><li>就会触发Service Worker的sync事件,在该监听的回调中进行操作,例如向后端发起请求</li><li>然后可以在Service Worker中对服务端返回的数据进行处理</li></ul><h4>页面触发同步</h4><pre><code>if ('serviceWorker' in navigator) {
navigator.serviceWorker.register('./sw.js')
navigator.serviceWorker.ready.then(function (registration) {
let tag = "data_sync";
document.getElementById('submit-btn').addEventListener('click', function () {
registration.sync.register(tag).then(function () {
console.log('后台同步已触发', tag);
}).catch(function (err) {
console.log('后台同步触发失败', err);
});
});
})
}</code></pre><p>由于后台同步功能需要在Service Worker注册完成后触发,所以我们可以使用navigator.serviceWorker.ready等待注册完成准备好之后使用 registration.sync.register 注册同步事件。</p><p>registration.sync 会返回一个SyncManager对象其中包含register方法和getTags方法。</p><p><img src="/img/remote/1460000044661441" alt="图片" title="图片"></p><h4>SW监听同步事件</h4><p>当页面触发同步事件后,我们需要通过Service Worker来处理sync事件。</p><pre><code>self.addEventListener('sync', function (e) {
let init = { method: 'GET' };
switch (e.tag){
case "data_sync":
let request = new Request(`xxxxx/sync`, init);
e.waitUntil(
fetch(request).then(function (response) {
return response;
})
);
break;
}
});</code></pre><h2>Taro项目集成</h2><p>理论说完了,接下来我们可以在taro项目里实践接入Service Worker。</p><p>俗话说,站在巨人的肩膀上看世界。</p><p>现在市面上实现SW的工具非常多,其中google团队提供了一个十分强大且完善的插件 workbox-webpack-plugin ,接下来我们将通过这个插件实现Service Worker的离线缓存功能。</p><h3>插件配置</h3><p>workbox-webpack-plugin提供了两个类名为 GenerateSW 和 InjectManifest,接下来我们通过使用GenerateSW来实现预缓存文件和简单的运行时缓存需求。</p><p>在taro项目负责打包的config文件中加入以下配置:</p><pre><code>const { GenerateSW } = require('workbox-webpack-plugin');
const config = {
...
h5: {
...
webpackChain(chain) {
...
chain.plugin('generateSW').use(new GenerateSW({
clientsClaim: true,
skipWaiting: true,
runtimeCaching: [
{
urlPattern: /.\/*/, // 需要缓存的路径
handler: 'StaleWhileRevalidate', // 缓存策略
options: {
cacheName: 'my-webcache',
expiration: {
maxEntries: 2000,
},
},
}],
}));
}
}
}</code></pre><p>加入以上配置后,我们运行build命令可以发现该插件为我们自动生成了Service Worker文件。</p><p><img src="/img/remote/1460000044661442" alt="图片" title="图片"></p><p><img src="/img/remote/1460000044661443" alt="图片" title="图片"></p><h3>Service Worker注册</h3><p>生成Service Worker文件之后我们需要在项目中进行注册。</p><p>在register文件中处理Service Worker的生命周期、状态等信息。</p><pre><code>import { register } from 'register-service-worker';
register('./service-worker.js', {
registrationOptions: { scope: './' },
ready(registration) {
console.log('Service worker is active.', registration);
},
registered(registration) {
console.log('Service worker has been registered.', registration);
},
cached(registration) {
console.log('Content has been cached for offline use.', registration);
},
updatefound(registration) {
console.log('New content is downloading.', registration);
},
updated(registration) {
console.log('New content is available; please refresh.', registration);
},
offline() {
console.log('No internet connection found. App is running in offline mode.');
},
error(error) {
console.error('Error during service worker registration:', error);
},
});</code></pre><p>在app.ts中引入该文件,我们就完成了简单的Service Worker的引入。接下来把项目启动,让我们看看SW是否生效。</p><p>在正常网络环境中,可以看到我们发起第一次访问的请求列表。</p><p><img src="/img/remote/1460000044661444" alt="图片" title="图片"></p><p>在把网络设置成离线状态后,可以看到我们的请求依然正常返回,并走的是Service Worker的缓存。</p><p><img src="/img/remote/1460000044661445" alt="图片" title="图片"></p><p>我们也可以在控制台看到所有缓存的文件列表。</p><p><img src="/img/remote/1460000044661446" alt="图片" title="图片"></p><p>总的来说,Service Worker是一个非常强大的功能,除了以上介绍的离线缓存和后台同步功能,还可以通过SW实现消息推送、多页面通信等等功能。</p><p>(本文作者:龚思晗)</p><p><img width="732" height="246" src="https://segmentfault.com/img/bVdajSo" alt="图片" title="图片"></p>
智能判责在哈啰顺风车的应用
https://segmentfault.com/a/1190000044644481
2024-02-21T10:42:08+08:00
2024-02-21T10:42:08+08:00
哈啰技术
https://segmentfault.com/u/hellotech
0
<h2>智能判责任务简介</h2><h3>智能判责定义</h3><p><img src="/img/remote/1460000044644483" alt="图片" title="图片"></p><p>在普惠顺风车订单系统中,一旦司机和乘客建立订单关系后,若其中任何一方发起取消订单的请求,将触发判责算法,该算法将输出确定订单取消责任的一方。</p><h3>智能判责难点</h3><ul><li>重要特征因子繁多:聊天记录、电话录音以及订单备注等皆属于最为关键的因素之一。然而,这些特征本身的形式多样,包括文字和录音等形式,因此其解读与消化变得相当困难。</li><li>训练样本匮乏:当前可用的训练样本中,判责结果的准确率本就不尽人意。因此,基于这些样本训练出的模型的可靠性也较低。</li><li>复杂的应用场景交叉:不同场景之间相互交织,例如订单取消可能因时间变更,也可能是因对方要求额外高速费或者额外增加人员等等问题。</li><li>特征数量与判断难度正相关:传统的算法模型认为特征越多准确率越高,然而,在智能判责领域,特征的增多意味着事务变得更加复杂,从而增加了模型准确判断的难度。</li></ul><h2>传统智能判责算法</h2><h3>工程判责:</h3><p>判责介绍:利用一定的规则进行判责(容易被发现漏洞) </p><h3>算法判责1.0_趋近客服改判:</h3><p>训练数据集:客服改判结果<br>特征因子:订单基本信息、司乘基本信息</p><h3>算法判责2.0_工程+客服改判:</h3><p>训练数据集:90%工程判责数据(用户有选择取消原因&未进入客服侧的工程判责数据)+10%客服改判数据(客服改判后用户未再进线的改判数据)</p><h3>算法判责3.0:</h3><p>训练数据集:数千条双盲精确打标数据<br>特征因子:订单基本信息、司乘基本信息+85%准确率的聊天记录意图识别模型</p><h2>结合大模型的智能判责</h2><h3>为什么要用大模型来进行判责</h3><p>目前,传统的智能责任判定算法在处理无沟通记录的情况下,准确率高达99.7%,但对于具有聊天记录和通话记录的案例,准确率稍有下降。而根据业务反馈,复杂案例需要自然人参与听取通话内容进行判定。</p><p>为了进一步提高准确率,关键的提升点在于文本和语音的语义理解。目前的深度学习模型在此方面达到了大约80%的准确率,但在特征融合过程中存在着较大的信息损失。</p><p>大模型在这方面具有独特的优势。同时,我们的责任判定场景有明确的SOP,可以通过一定手段来确保模型准确率达到可接受的水平。此外,大模型还能够提供责任判定的原因,这是以前的深度学习模型所无法实现的。</p><p>一个例子感受下大模型的强大之处:<br><img src="/img/remote/1460000044644484" alt="图片" title="图片"></p><p>输入:订单的上下文信息和司乘聊天信息。<br><img src="/img/remote/1460000044644485" alt="图片" title="图片"></p><p>输出:明确按照我给的判责流程进行判责工作,并给出推理过程和判责依据。</p><h3>Prompt工程</h3><p><img src="/img/remote/1460000044644486" alt="图片" title="图片"></p><p>Prompt工程是创建Prompt、提问或指导像ChatGPT这样的语言模型输出的过程。</p><p>它允许用户控制模型的输出,生成符合其特定需求的文本。Prompt 工程的作用,通过提供清晰和具体的指令,可以引导模型的输出,确保其相关性。</p><p>简单举例,想创建一个负责生成文本的机器人,提示词需要遵循以下四个要点:</p><ul><li>以第二人称而不是第三人称称呼机器人,让机器有自我认知</li><li>措辞尽可能地清晰,减少误解</li><li>可以在提示中使用方括号对指令进行扩展描述</li><li>使用Markdown有时可以帮助机器人更好地理解复杂的指令</li></ul><p><img src="/img/remote/1460000044644487" alt="图片" title="图片"></p><p>这里的关键不在技术,而在提示词的质量与创作者的脑洞,以及善于利用AI解决问题的能力——“人机交互”能力。</p><p>随着使用越来越多大家也发现大模型直接给出答案似乎并不靠谱,那么是否可以让它像人类一样,一步一步思考呢?毕竟,人类在解决问题时,也是逐渐构建解决方案,而并非立即给出答案。因此,开始出现了一系列的尝试解法,比如思维链、多思维链、思维树和思维图等。</p><p><img src="/img/remote/1460000044644488" alt="图片" title="图片"></p><p>这可以通过两种方式实现,一种是具体说明,即要求模型详细地、一步步地思考;另一种是示例说明,即通过给定问题和答案的同时,提供思考过程。这样,当询问模型时,模型会模仿此过程,逐渐思考并给出答案。</p><h3>外挂知识库</h3><p><img src="/img/remote/1460000044644489" alt="图片" title="图片"></p><p>步骤:</p><ul><li>将知识库的文本分块,并进行向量化(可以使用大模型的embedding也可以使用如BERT等方法)</li><li>用户的query向量化,并在知识库中进行检索,返回最相关的TOPN的文本块</li><li>采用合适的prompt + 上述步骤搜索到的文本,一并输入给LLM</li><li>利用LLM的语义理解能力和知识问答能力,生成问题的答案</li></ul><p>缺点很明显:只是匹配到最相关部分,不是理解全部语义,准确率有损。</p><h3>Agent</h3><p>Agents=LLM + 任务规划(COT) + 记忆(LangChain) + 工具使用</p><p>其中LLM是核心大脑,记忆、任务规划和工具使用则是Agents系统实现的三个关键组件。再加上行动端,形成一个完整的Agent System。</p><p><img src="/img/remote/1460000044644490" alt="图片" title="图片"></p><ul><li>记忆(Memory)<br>记忆可以定义为获取、存储、保留和稍后检索信息的过程,其中包括储存了Agent过去的观察、思考和行动序列的信息。</li><li>短期记忆(Short-term memory)<br>由于Transformer模型的上下文窗口有限,短期记忆是一种短暂且有限的记忆形式;为了应对这种限制,目前有以下解决方案:<br>1.扩展主干架构的长度限制:通过改进Transformer模型固有的序列长度限制问题来提高短期记忆的容量。比如gpt4-8k, gpt4-32k, gpt4-128k。<br>2.总结记忆(Summarizing):对记忆进行摘要总结,增强Agent从记忆中提取关键细节的能力,例如LangChain的Conversation Summary Buffer Memory</li><li>长期记忆(Long-term memory)<br>长期记忆是AI Agent可以在查询时处理的外部向量存储,可以通过快速检索访问,并使用适当的数据结构对记忆进行压缩,以提高记忆检索效率。</li><li>工具使用(Tool Use)<br>人类通过使用工具来完成超出我们身体和认知极限的任务。同样地,给LLM配备外部工具也可以显著扩展大模型的功能,使其能够处理更加复杂的任务。如联网工具、访问外部一切API的能力、访问业务服务API的能力。</li><li>任务规划(Planning Skills)<br>在具体实现中,规划可以包含两个步骤:<br>1.计划制定(Plan Formulation):代理将复杂任务分解为更易于管理的子任务。<br>一次性分解再按顺序执行、逐步规划并执行、多路规划并选取最优路径等。<br>在一些需要专业知识的场景中,代理可与特定领域的 Planner 模块(SOP)集成,提升能力。<br>2.计划反思(Plan Reflection):在制定计划后,可以进行反思并评估其优劣。这种反思一般来自三个方面:借助内部反馈机制;与人类互动获得反馈;从环境中获得反馈。</li></ul><p><img src="/img/remote/1460000044644491" alt="图片" title="图片"></p><p>我们把Agent视作一个虚拟世界中的智能体,如MineCraft游戏中所设定的角色。这个角色可以沿着指定的路线,完成一些在环境中探索的任务,如建房子、挖矿、打怪等。这个角色首先需要被告知怎样去执行任务,例如自动训练课程计划的使用。然后逐步的完成任务,形成自己的执行代码库、技能库等,这样就算是在以后遇到相似的任务,它都能快速调用已有的技能和经验来完成任务。某种意义上,这就是一种强化学习的方式。</p><h3>结合大模型的智能判责</h3><p>之前的介绍的方案都是在不改变大模型原有参数的情况下。</p><p>在我们探索大模型的应用过程中,从prompt工程转向微调方案是一个重要的步骤。这个转变涉及到模型的训练和优化方式的根本性改变。</p><p>而微调方案都是在预训练模型的基础上,通过微调部分参数,来适应特定的任务。微调方案主要包括Freeze、prompt tuning、LoRA等。</p><p>这两种方法各有优势,Prompt工程的优点在于其高效性和灵活性,而微调方案则可以更精细地调整模型以适应特定任务。而且通常是结合起来使用。</p><ul><li>Lora方法</li></ul><p><img src="/img/remote/1460000044644492" alt="图片" title="图片"></p><p>LoRA(Low-Rank Adaptation of Large Language Models),直译为大语言模型的低阶自适应。LoRA 的基本原理是冻结预训练好的模型权重参数,在冻结原模型参数的情况下,通过往模型中加入额外的网络层,并只训练这些新增的网络层参数。由于这些新增参数数量较少,这样不仅 finetune 的成本显著下降,还能获得和全模型参数参与微调类似的效果。</p><p>随着大语言模型的发展,模型的参数量越来越大,比如 GPT-3 参数量已经高达 1750 亿,因此,微调所有模型参数变得不可行。LoRA 微调方法由微软提出,通过只微调新增参数的方式,大大减少了下游任务的可训练参数数量。</p><p>A的输入维度和B的输出维度分别与原始模型的输入输出维度相同,而A的输出维度和B的输入维度是一个远小于原始模型输入输出维度的值,这也就是low-rank的体现(有点类似Resnet的结构),这样做就可以极大地减少待训练的参数了。</p><p>我们也尝试了lora微调实验,在同样的prompt和相同基座下准确率由之前的32%提升至微调后的60%。</p><p>(本文作者:江涛)</p><p><img width="732" height="246" src="https://segmentfault.com/img/bVdajSo" alt="图片" title="图片"></p>
多场景静态化编译在两轮SAAS用车实践
https://segmentfault.com/a/1190000044598537
2024-01-29T16:15:11+08:00
2024-01-29T16:15:11+08:00
哈啰技术
https://segmentfault.com/u/hellotech
0
<h2>背景</h2><p>在用车saas化推广兼容小品牌用车的过程中,由于用户交互、接口数据、业务流程在主品牌与小品牌之间存在差异性,导致代码分叉过多,影响可读性与可编辑性;两侧用车能力存在部分混入,造成运行时代码过大;单一场景迭代容易干扰其他端侧用车能力;后续还会拓展到端外用车,上述问题会继续放大。</p><h2>什么是静态化编译</h2><p>简单来说,本文中「静态化编译」等同于程序运行中的「编译时」,与「运行时」是相对的。也就是说主要在程序编译阶段,就得把相关代码打包进去产物中,来降低运行时的压力。业务静态化编译,也就是在开发阶段就把不同端口难以融合的业务逻辑以不同文件的形式进行拆分,这里的不同文件是指不同的文件名后缀,文件名是相同的。来到编译环节,通过webpack resolve plugin来对文件名及其后缀进行分叉与打包。</p><h3>目标与价值</h3><ul><li>实现差异化场景隔离,共享核心用车能力;</li><li>多场景业务逻辑拆分,降低测试回归工作量;</li><li>降低研发、维护阶段对于其他端额外影响和风险;</li><li>实现差异化打包构建,降低包体积,提高秒开率;</li><li>提高业务代码整洁性和独立性;</li><li>为实现业务能力编排提供技术可能性。</li></ul><h3>使用场景</h3><p><strong>业务隔离</strong><br>营销能力、全局通知栏、临时锁车、头盔能力、响铃寻车、更多业务…<br><strong>样式隔离</strong><br>开锁页底部交互卡片、骑行中底部交互卡片、lottie动画能力、popup弹窗能力<br><strong>基础能力隔离</strong><br>蓝牙能力 @hb/hb/bluetooth-taro、车码识别能力 @hb/taro-core-monorepo/scan-parser、webView承载页(主品牌使用平台webView承载页;小品牌独立)</p><h2>如何实现业务静态化编译</h2><p>对于解决静态化编译,首先需要解决业务逻辑分叉问题;其次开发resolve插件系统,对于分叉代码按照文件名后缀进行提取与编译。</p><h3>古早时期(未使用多场景静态化编译方案前)</h3><p><img src="/img/remote/1460000044598539" alt="图片" title="图片"></p><p>上述图片是业务代码中分别引入小品牌与主品牌全局通知栏业务hook,只有在运行时才能根据当前环境是小品牌或主品牌来进行区分端口。这种方案的缺陷很明显:运行时过大,直接影响包体积;代码分叉太多,影响可读性。</p><p><img src="/img/remote/1460000044598540" alt="图片" title="图片"></p><p>上述图片是构建后产物,不管当前环境是小品牌还是主品牌,其他场景逻辑都在在产物中,影响其体积。</p><h3>当下及以后(使用多场景静态化编译方案后)</h3><p><img src="/img/remote/1460000044598541" alt="图片" title="图片"></p><p>引入方式终结在文件夹之后,不用指定当前的具体文件名及其后缀。</p><p><img src="/img/remote/1460000044598542" alt="图片" title="图片"></p><p>这是构建小品牌场景后的产物,如果是主品牌的话,产物中后缀则为.../*.index.oho.ts。</p><h3>目录结构</h3><p><img src="/img/remote/1460000044598543" alt="图片" title="图片"></p><p>上述图片是老的目录结构:<br>只有通过文件名来进行区分不同业务类型:<br>/mini.ts:小品牌;/oho.ts:主品牌</p><p><img src="/img/remote/1460000044598544" alt="图片" title="图片"></p><p>上述图片是新的目录结构:<br>通过文件名后缀区分不同业务类型:<br><em>.mini.ts:小品牌包含了</em>.mini.alipay.ts,*.mini.weapp.ts;<br><em>.oho.ts:主品牌,</em>.oho.alipay.ts,*.oho.weapp.ts基础</p><h3>require的局限性</h3><p><img src="/img/remote/1460000044598545" alt="图片" title="图片"></p><ul><li>只限于node环境;</li><li>只会解析.js .json .node文件;</li><li>只会去解析文件的完整路径,不会解析文件夹;</li><li>解析文件成功后返回值仅仅是一个路径,没有包含描述文件等较为丰富的数据。</li></ul><h3>创建插件</h3><p>在创建插件时一般会传入source和target两个参数:</p><ul><li>source:插件拿到Resolver.hooks['source']钩子,并调tap或tapAsync添加处理函数事件。当解析器接收到了source事件时,会执行注册的处理函数;</li><li>target:在处理完毕后,调用doResolve触发一个target事件,交由下一个监听target事件的插件处理。</li></ul><p><img src="/img/remote/1460000044598546" alt="图片" title="图片"></p><p>有了注册事件tapAsync和触发事件doResolve,各个插件就可以像积木一样链接起来。</p><h3>Tapable介绍</h3><p><strong>简介</strong><br>Tapable 是一个类似于 Node.js 中的 EventEmitter 的库,但它更专注于自定义事件的触发和处理。通过 Tapable 我们可以注册自定义事件,然后在适当的时机去执行自定义事件。这个和我们所熟知的生命周期函数类似,在特定的时机去触发。</p><p>Tapable是一个由Webpack团队维护的事件库,它基于发布订阅模式实现事件处理。在Webpack中,Compiler和Compilation是Webpack的内置对象,它们都继承于Tapable。通过使用Tapable,这些对象可以触发事件,从而将不同的插件串联起来。这种机制使得Webpack可以在不同的编译阶段调用不同的插件,从而影响编译结果。</p><p>更具体地说,Tapable提供了一系列事件的发布订阅API,允许注册事件,然后在适当的时机触发这些注册的事件进行执行。这些注册的事件可以分为同步和异步两种执行方式。同步钩子可以使用tap方法进行注册,并通过call方法触发执行。</p><p>总的来说,Tapable在Webpack中起到了一个核心的作用,它连接了Webpack的各个插件,使它们能够按照设定的逻辑和时机进行交互和执行,从而实现了Webpack的灵活性和扩展性。</p><p><strong>分类</strong><br><img src="/img/remote/1460000044598547" alt="图片" title="图片"></p><p><strong>简易demo</strong><br>AsyncSeriesBailHook 是一个异步串行、熔断类型的 Hook。在串行的执行过程中,只要其中一个有返回值,后面的就不会执行了。这里列举该hook是因为后续resolve众多plugin之中对于插件系统中hook的注册,全部都是使用该钩子。</p><p><img src="/img/remote/1460000044598548" alt="图片" title="图片"></p><p><img src="/img/remote/1460000044598549" alt="图片" title="图片"></p><p><strong>enhanced-resolve介绍</strong><br>enhanced-resolve是webpack的一个核心包,用于增强webpack的模块解析能力,使其更容易找到所需的模块,从而提高webpack的性能和可维护性。</p><p>具体来说,enhanced-resolve可以为webpack解析器添加额外的搜索路径以及解析规则,让webpack更好地解释路径和文件,进而让webpack更加专心地做模块打包相关的事情。</p><p>总的来说,enhanced-resolve对于webpack的作用是增强模块解析能力,提高性能和可维护性,使webpack更加专注于模块打包相关的工作。</p><p><strong>流水线</strong><br><img src="/img/remote/1460000044598550" alt="图片" title="图片"></p><p><img src="/img/remote/1460000044598551" alt="图片" title="图片"></p><p><img src="/img/remote/1460000044598552" alt="图片" title="图片"></p><p>不同的hooks之间通过Resolver中的doResolve串联起来,一个hook中可以包含多个plugin。</p><p><strong>插件流转</strong><br><img src="/img/remote/1460000044598553" alt="图片" title="图片"></p><p>这里描述了插件在各个hooks上的注册过程。可以将hooks理解成弹夹,plugin理解成子弹,插件的注册过程也就是给多个弹夹装子弹的过程。</p><p><strong>Resolver实例创建与解析</strong><br><img src="/img/remote/1460000044598554" alt="图片" title="图片"></p><ul><li>ResolverFactory.createResolver 根据 Resolver 类创建实例:myResolve (吃了配置,吐出对象myResolve)</li><li>myResolve 上 注册并订阅 大量的 hook (枪支弹药贮备好,一刻激发)</li><li>调用 myResolver.resolve 方法开始进行 文件解析 的主流程</li><li>内部通过 resolve.doResolve方法,开始调用第一个 hook: this.hooks.resolve</li><li>找到之前 订阅 hook 的 plugin:ParsePlugin</li><li>ParsePlugin 进行初步解析,然后 通过doResolve 执行下一个 hook parsed-resolve,前期准备工作结束,链式调用开始,真正的解析文件的流程也开始。</li></ul><p><strong>插件装配</strong><br><img src="/img/remote/1460000044598555" alt="图片" title="图片"></p><p><img src="/img/remote/1460000044598556" alt="图片" title="图片"></p><h2>集成静态化编译能力</h2><ol><li>安装@hb/multi-scene-resolve-plugin_自定义编译文件npm包;</li></ol><p><img src="/img/remote/1460000044598557" alt="图片" title="图片"></p><ol start="2"><li>项目配置文件中引入上述npm包;</li></ol><p><img src="/img/remote/1460000044598559" alt="图片" title="图片"></p><ol start="3"><li>替换taro内置multiPlatformPlugin插件。</li></ol><p><img src="/img/remote/1460000044598560" alt="图片" title="图片"></p><h2>使用提示</h2><p>统一接口的多场景文件这一跨平台兼容写法有如下三个使用要点:</p><ul><li>不同端的对应文件一定要统一接口和调用方式。</li><li>引用文件的时候,只需要写默认文件名,不用带文件后缀。</li><li>最好有一个平台无关的默认文件,这样在使用 TS 的时候也不会出现报错。</li></ul><h2>Q&A</h2><p>1.为啥必须用MultiSceneResolvePlugin插件覆盖Taro MultiPlatformPlugin自带插件,而不是并行处理?</p><p>由于MultiPlatformPlugin解析不了以下文件路径:/pages/riding/useHelmet.oho.weapp.ts,所以只能覆盖Taro自身插件;并且重复解析会提高编译时间。</p><p><img src="/img/remote/1460000044598561" alt="图片" title="图片"></p><p>2.插件注册的时机or钩子一定是described-resolve和resolve嘛?</p><p>不一定,只要是在文件名或后缀完成绑定前都可以,比如before-file,file等。</p><p>3.为啥不能调整MultiSceneResolvePlugin插件与MultiPlatformPlugin的顺序,使其先执行前者?</p><p>MultiPlatformPlugin插件Taro自带插件,集成在@tarojs/mini-runner和@tarojs/webpack-runner中,通过chain.merge合并自定义配置项,执行优先级高于自定义。而且如果重置了,解析出带有后缀的完整路径后会影响MultiPlatformPlugin中对于处理文件的筛选条件。</p><p>4.为啥不编写taro插件而编写webpack插件?</p><p>taro插件系统从3.6.3版本才系统性的支持,而且webpack插件在系统中更为通用。</p><p>(本文作者:刘广永)</p><p><img width="732" height="246" src="https://segmentfault.com/img/bVdajSo" alt="图片" title="图片"></p>
街猫自研多媒体能力介绍
https://segmentfault.com/a/1190000044587971
2024-01-25T11:12:08+08:00
2024-01-25T11:12:08+08:00
哈啰技术
https://segmentfault.com/u/hellotech
0
<h2>背景</h2><p>哈啰街猫移动团队在支撑业务发展过程中,已有的多媒体基础能力存在一些问题/瓶颈:</p><ul><li>猫屋直播 - 三方直播sdk,在MTK芯片的机型上存在兼容问题(hevc硬解报错),导致直播流无法播放,用户无法使用app的核心功能</li><li>音视频流合成、滤镜 - 需要能够灵活的支持用户去触发对猫屋直播流的截取、合成、添加滤镜等,使用系统多媒体Api,在可扩展性,流处理效率,兼容性,以及滤镜的支持上,都存在问题</li><li>视频转码 - 猫友圈上传的用户视频,需要对其做转码后发布,转码最大的问题是,源视频的格式和参数是不确定的(Android),如果只用系统多媒体Api去处理,会存在各种各样的兼容性问题,结果要么是转码失败,要么视频发布后,其他端的用户观看异常</li></ul><p>所以需要自研多媒体框架去解决/优化上述问题,以便后续能够更好的支撑业务发展。</p><h2>街猫自研多媒体架构</h2><p><img src="/img/remote/1460000044587973" alt="图片" title="图片"></p><h3>硬编解码</h3><p>支持Android 7.0以后,将media codec部分从media player service里抽离出来,单独开了一条新的binder服务media.codec来开放系统的硬编解码能力,系统要开放,必须要有标准,让各个硬件平台根据标准来开发其硬编解码器,然后集成到media.codec中,这个协议就是OpenMax。</p><p>OpenMax分为三层:</p><ul><li>DL - 开发层</li><li>IL - 集成层</li><li>AL- 应用层</li></ul><p><img src="/img/remote/1460000044587974" alt="图片" title="图片"></p><p>OpenMax是多媒体框架标准,目前应用最广泛的是IL层,各个硬件平台只需要依托IL层协议,提供统一的抽象层接口,屏蔽各自在底层适配时存在的差异,最终打包到libstagefrighthw.so,由media codec service加载后,以binder服务的形式,将硬编解码能力开放给有需要的多媒体应用,包括OpenMax AL,ffmpeg,nuplayer,exoplayer,街猫多媒体组件等等。</p><h3>多媒体底层框架 - FFMPEG</h3><p>街猫多媒体底层依托于ffmpeg,从而具备了覆盖多媒体全应用场景的底层能力,基于ffmpeg 4.2.2源码,我们目前主要做了如下定制化:</p><ul><li>修改编解码配置,使ffmpeg支持flv&h265视频流</li><li>添加h264&h265编解码codec,依托Android Ndk MediaCodec Api,使ffmpeg具备h264&h265格式的硬编解码能力</li><li>添加h264软编码codec,使ffmpeg具备h264软编码能力</li></ul><h3>自研多媒体组件</h3><table><thead><tr><th>组件名称</th><th>描述</th></tr></thead><tbody><tr><td>街猫多媒体核心组件</td><td>包含街猫多媒体基础java和c++代码实现, 包含两个核心的c++库:1. libpet-media-core.so 包含ffmpeg4.2.2的代码和街猫多媒体转码,直播,视频合成的核心实现(平台无关),2. libpet-media-compat.so android和ios的代理库,包含android和ios平台兼容性c++实现</td></tr><tr><td>街猫多媒体转码组件</td><td>依赖多媒体核心组件,包含转码的java层实现核心能力:<em> 支持全格式转码,优先使用硬编码,如果设备不支持,则自动降级为软编码 </em> 支持码率,分辨率,fps,gop等参数配置 <em> 支持对源视频指定区间转码 </em> 转码后视频编码格式默认为h264,音频aac <em> 转码完成后,对生成视频做有效性校验,确保转码符合要求 </em> 转码后MP4视频全部moov前置</td></tr><tr><td>街猫多媒体直播组件</td><td>依赖多媒体核心组件,包含flv直播流的java层实现,核心能力:<em> 支持flv hevc格式的直播流的播放,支持软硬解码动态切换,相对三方库纯硬解码,具有更好的兼容性, </em> api的设计跟三方sdk完全保持一致,业务层无缝接入</td></tr><tr><td>街猫多媒体视频合成组件</td><td>依赖多媒体核心组件,包含音视频合成的java层实现, 核心能力:<em> 支持输入视频和音频数据流,合成h264编码格式的mp4文件 </em> 视频格式支持yuv420p&nv12(格式可扩展) <em> 音频输入pcm数据,支持Packed和Planar两种格式,也可不设置音频(合成视频无声音) </em> 支持添加logo和名称水印滤镜(滤镜可扩展) <em> 支持配置合成视频的片尾视频 </em> 合成视频的编码格式,码率、软硬编码等可配置</td></tr></tbody></table><h2>业务成果</h2><h3>街猫转码</h3><ul><li>将转码覆盖率从原先的50%以下,提高到99%以上,统一转码后的视频格式,通过转码后视频格式的有效性校验,确保99%可播放,从而彻底解决5+线上遗留问题</li><li>覆盖率提高后,视频的平均压缩率提高30+pp,降低了云端存储和流量费用,提高用户的观看体验</li><li>转码后视频moov全部前置,提高转码后视频的秒开率</li></ul><h3>街猫直播</h3><ul><li>完美解决了由于三方播放器兼容性不足导致部分机型无法播放的问题,播放率提高5+pp</li></ul><h3>街猫合成</h3><ul><li>合成和水印能力,深度应用于投喂一刻和直播间聊天视频录制,功能上线后,大幅提高了内容点击率,猫友圈转发率和发言提升率</li></ul><h2>FFMPEG介绍</h2><h3>核心库</h3><ul><li>libavcodec - 包含音频和视频的编解码器</li><li>libavutil - 包含编码所需的各种使用工具,包括随机数生成,常用数据结构,多媒体相关基础工具等等</li><li>libavformat - 包含封装和解封装的实现</li><li>libavfilter - 包含多媒体滤镜的实现</li><li>libavdevice - 包含设备输入和输出相关实现</li><li>libswscale - 包含视频格式转码实现</li><li>libswresample - 包含音频重采样的实现</li></ul><h3>音视频播放流程</h3><p><img src="/img/remote/1460000044587975" alt="图片" title="图片"></p><p>上图是视频播放器的基本流程,source、demux、decoder、output</p><ul><li>source:数据源,数据的来源不一定都是本地file,也有可能是网路上的各种协议例如:http、rtsp、HLS等。source的任务就是把数据源抽象出来,为下一个demux模块提供它需要的稳定的数据流。demux不用关信数据到底是从什么地方来的。</li><li>demux解复用:视频文件一般情况下都是把音视频的encoded streams交织的通过某种规则放在一起。这种规则就是容器规则。现在有很多不同的容器格式。如ts、mp4、flv、mkv、avi、rmvb等等。demux的功能就是把音视频的ES流从容器中剥离出来,然后分别送到不同的解码器中。其实音频和视频本身就是2个独立的系统。容器把它们包在了一起。但是他们都是独立解码的,所以解码之前,需要把它分别 独立出来。demux就是干这活的,他为下一步decoder解码提供了数据流。</li><li>decoder解码:解码器 - 播放器的核心模块,分为音频和视频解码器。影像在录制后, 原始的音视频都是占用大量空间, 而且是冗余度较高的数据. 因此, 通常会在制作的时候就会进行某种压缩 ( 压缩技术就是将数据中的冗余信息去除数据之间的相关性 ). 这就是我们熟知的音视频编码格式, 包括MPEG1(VCD)\ MPEG2(DVD)\ MPEG4 \ H.264 等等. 音视频解码器的作用就是把这些压缩了的数据还原成原始的音视频数据. 当然, 编码解码过程基本上都是有损的 .解码器的作用就是把编码后的数据还原成原始数据。</li><li>output输出:输出部分分为音频和视频输出。解码后的音频(pcm)和视频(yuv)的原始数据需要得到音视频的output模块的支持才能真正的让人的感官系统(眼和耳)辨识到。</li></ul><p>市面上绝大多数播放器的基本结构都是如此,不同的是在实现方式上会存在差异。</p><h2>一个简单的FFMPEG工程</h2><p>下面拿ffmpeg/examples/transcoding.c做介绍,这是一个转码的参考工程:</p><pre><code>#include <libavcodec/avcodec.h>
#include <libavformat/avformat.h>
#include <libavfilter/buffersink.h>
#include <libavfilter/buffersrc.h>
#include <libavutil/opt.h>
#include <libavutil/pixdesc.h>
static AVFormatContext *ifmt_ctx;
static AVFormatContext *ofmt_ctx;
typedef struct FilteringContext {
AVFilterContext *buffersink_ctx;
AVFilterContext *buffersrc_ctx;
AVFilterGraph *filter_graph;
} FilteringContext;
static FilteringContext *filter_ctx;
typedef struct StreamContext {
AVCodecContext *dec_ctx;
AVCodecContext *enc_ctx;
} StreamContext;
static StreamContext *stream_ctx;
static int open_input_file(const char *filename)
{
int ret;
unsigned int i;
ifmt_ctx = NULL;
if ((ret = avformat_open_input(&ifmt_ctx, filename, NULL, NULL)) < 0) {
av_log(NULL, AV_LOG_ERROR, "Cannot open input file\n");
return ret;
}
if ((ret = avformat_find_stream_info(ifmt_ctx, NULL)) < 0) {
av_log(NULL, AV_LOG_ERROR, "Cannot find stream information\n");
return ret;
}
stream_ctx = av_mallocz_array(ifmt_ctx->nb_streams, sizeof(*stream_ctx));
if (!stream_ctx)
return AVERROR(ENOMEM);
for (i = 0; i < ifmt_ctx->nb_streams; i++) {
AVStream *stream = ifmt_ctx->streams[i];
AVCodec *dec = avcodec_find_decoder(stream->codecpar->codec_id);
AVCodecContext *codec_ctx;
if (!dec) {
av_log(NULL, AV_LOG_ERROR, "Failed to find decoder for stream #%u\n", i);
return AVERROR_DECODER_NOT_FOUND;
}
codec_ctx = avcodec_alloc_context3(dec);
if (!codec_ctx) {
av_log(NULL, AV_LOG_ERROR, "Failed to allocate the decoder context for stream #%u\n", i);
return AVERROR(ENOMEM);
}
ret = avcodec_parameters_to_context(codec_ctx, stream->codecpar);
if (ret < 0) {
av_log(NULL, AV_LOG_ERROR, "Failed to copy decoder parameters to input decoder context "
"for stream #%u\n", i);
return ret;
}
/* Reencode video & audio and remux subtitles etc. */
if (codec_ctx->codec_type == AVMEDIA_TYPE_VIDEO
|| codec_ctx->codec_type == AVMEDIA_TYPE_AUDIO) {
if (codec_ctx->codec_type == AVMEDIA_TYPE_VIDEO)
codec_ctx->framerate = av_guess_frame_rate(ifmt_ctx, stream, NULL);
/* Open decoder */
ret = avcodec_open2(codec_ctx, dec, NULL);
if (ret < 0) {
av_log(NULL, AV_LOG_ERROR, "Failed to open decoder for stream #%u\n", i);
return ret;
}
}
stream_ctx[i].dec_ctx = codec_ctx;
}
av_dump_format(ifmt_ctx, 0, filename, 0);
return 0;
}
static int open_output_file(const char *filename)
{
AVStream *out_stream;
AVStream *in_stream;
AVCodecContext *dec_ctx, *enc_ctx;
AVCodec *encoder;
int ret;
unsigned int i;
ofmt_ctx = NULL;
avformat_alloc_output_context2(&ofmt_ctx, NULL, NULL, filename);
if (!ofmt_ctx) {
av_log(NULL, AV_LOG_ERROR, "Could not create output context\n");
return AVERROR_UNKNOWN;
}
for (i = 0; i < ifmt_ctx->nb_streams; i++) {
out_stream = avformat_new_stream(ofmt_ctx, NULL);
if (!out_stream) {
av_log(NULL, AV_LOG_ERROR, "Failed allocating output stream\n");
return AVERROR_UNKNOWN;
}
in_stream = ifmt_ctx->streams[i];
dec_ctx = stream_ctx[i].dec_ctx;
if (dec_ctx->codec_type == AVMEDIA_TYPE_VIDEO
|| dec_ctx->codec_type == AVMEDIA_TYPE_AUDIO) {
/* in this example, we choose transcoding to same codec */
encoder = avcodec_find_encoder(dec_ctx->codec_id);
if (!encoder) {
av_log(NULL, AV_LOG_FATAL, "Necessary encoder not found\n");
return AVERROR_INVALIDDATA;
}
enc_ctx = avcodec_alloc_context3(encoder);
if (!enc_ctx) {
av_log(NULL, AV_LOG_FATAL, "Failed to allocate the encoder context\n");
return AVERROR(ENOMEM);
}
/* In this example, we transcode to same properties (picture size,
* sample rate etc.). These properties can be changed for output
* streams easily using filters */
if (dec_ctx->codec_type == AVMEDIA_TYPE_VIDEO) {
enc_ctx->height = dec_ctx->height;
enc_ctx->width = dec_ctx->width;
enc_ctx->sample_aspect_ratio = dec_ctx->sample_aspect_ratio;
/* take first format from list of supported formats */
if (encoder->pix_fmts)
enc_ctx->pix_fmt = encoder->pix_fmts[0];
else
enc_ctx->pix_fmt = dec_ctx->pix_fmt;
/* video time_base can be set to whatever is handy and supported by encoder */
enc_ctx->time_base = av_inv_q(dec_ctx->framerate);
} else {
enc_ctx->sample_rate = dec_ctx->sample_rate;
enc_ctx->channel_layout = dec_ctx->channel_layout;
enc_ctx->channels = av_get_channel_layout_nb_channels(enc_ctx->channel_layout);
/* take first format from list of supported formats */
enc_ctx->sample_fmt = encoder->sample_fmts[0];
enc_ctx->time_base = (AVRational){1, enc_ctx->sample_rate};
}
if (ofmt_ctx->oformat->flags & AVFMT_GLOBALHEADER)
enc_ctx->flags |= AV_CODEC_FLAG_GLOBAL_HEADER;
/* Third parameter can be used to pass settings to encoder */
ret = avcodec_open2(enc_ctx, encoder, NULL);
if (ret < 0) {
av_log(NULL, AV_LOG_ERROR, "Cannot open video encoder for stream #%u\n", i);
return ret;
}
ret = avcodec_parameters_from_context(out_stream->codecpar, enc_ctx);
if (ret < 0) {
av_log(NULL, AV_LOG_ERROR, "Failed to copy encoder parameters to output stream #%u\n", i);
return ret;
}
out_stream->time_base = enc_ctx->time_base;
stream_ctx[i].enc_ctx = enc_ctx;
} else if (dec_ctx->codec_type == AVMEDIA_TYPE_UNKNOWN) {
av_log(NULL, AV_LOG_FATAL, "Elementary stream #%d is of unknown type, cannot proceed\n", i);
return AVERROR_INVALIDDATA;
} else {
/* if this stream must be remuxed */
ret = avcodec_parameters_copy(out_stream->codecpar, in_stream->codecpar);
if (ret < 0) {
av_log(NULL, AV_LOG_ERROR, "Copying parameters for stream #%u failed\n", i);
return ret;
}
out_stream->time_base = in_stream->time_base;
}
}
av_dump_format(ofmt_ctx, 0, filename, 1);
if (!(ofmt_ctx->oformat->flags & AVFMT_NOFILE)) {
ret = avio_open(&ofmt_ctx->pb, filename, AVIO_FLAG_WRITE);
if (ret < 0) {
av_log(NULL, AV_LOG_ERROR, "Could not open output file '%s'", filename);
return ret;
}
}
/* init muxer, write output file header */
ret = avformat_write_header(ofmt_ctx, NULL);
if (ret < 0) {
av_log(NULL, AV_LOG_ERROR, "Error occurred when opening output file\n");
return ret;
}
return 0;
}
static int init_filter(FilteringContext* fctx, AVCodecContext *dec_ctx,
AVCodecContext *enc_ctx, const char *filter_spec)
{
char args[512];
int ret = 0;
const AVFilter *buffersrc = NULL;
const AVFilter *buffersink = NULL;
AVFilterContext *buffersrc_ctx = NULL;
AVFilterContext *buffersink_ctx = NULL;
AVFilterInOut *outputs = avfilter_inout_alloc();
AVFilterInOut *inputs = avfilter_inout_alloc();
AVFilterGraph *filter_graph = avfilter_graph_alloc();
if (!outputs || !inputs || !filter_graph) {
ret = AVERROR(ENOMEM);
goto end;
}
if (dec_ctx->codec_type == AVMEDIA_TYPE_VIDEO) {
buffersrc = avfilter_get_by_name("buffer");
buffersink = avfilter_get_by_name("buffersink");
if (!buffersrc || !buffersink) {
av_log(NULL, AV_LOG_ERROR, "filtering source or sink element not found\n");
ret = AVERROR_UNKNOWN;
goto end;
}
snprintf(args, sizeof(args),
"video_size=%dx%d:pix_fmt=%d:time_base=%d/%d:pixel_aspect=%d/%d",
dec_ctx->width, dec_ctx->height, dec_ctx->pix_fmt,
dec_ctx->time_base.num, dec_ctx->time_base.den,
dec_ctx->sample_aspect_ratio.num,
dec_ctx->sample_aspect_ratio.den);
ret = avfilter_graph_create_filter(&buffersrc_ctx, buffersrc, "in",
args, NULL, filter_graph);
if (ret < 0) {
av_log(NULL, AV_LOG_ERROR, "Cannot create buffer source\n");
goto end;
}
ret = avfilter_graph_create_filter(&buffersink_ctx, buffersink, "out",
NULL, NULL, filter_graph);
if (ret < 0) {
av_log(NULL, AV_LOG_ERROR, "Cannot create buffer sink\n");
goto end;
}
ret = av_opt_set_bin(buffersink_ctx, "pix_fmts",
(uint8_t*)&enc_ctx->pix_fmt, sizeof(enc_ctx->pix_fmt),
AV_OPT_SEARCH_CHILDREN);
if (ret < 0) {
av_log(NULL, AV_LOG_ERROR, "Cannot set output pixel format\n");
goto end;
}
} else if (dec_ctx->codec_type == AVMEDIA_TYPE_AUDIO) {
buffersrc = avfilter_get_by_name("abuffer");
buffersink = avfilter_get_by_name("abuffersink");
if (!buffersrc || !buffersink) {
av_log(NULL, AV_LOG_ERROR, "filtering source or sink element not found\n");
ret = AVERROR_UNKNOWN;
goto end;
}
if (!dec_ctx->channel_layout)
dec_ctx->channel_layout =
av_get_default_channel_layout(dec_ctx->channels);
snprintf(args, sizeof(args),
"time_base=%d/%d:sample_rate=%d:sample_fmt=%s:channel_layout=0x%"PRIx64,
dec_ctx->time_base.num, dec_ctx->time_base.den, dec_ctx->sample_rate,
av_get_sample_fmt_name(dec_ctx->sample_fmt),
dec_ctx->channel_layout);
ret = avfilter_graph_create_filter(&buffersrc_ctx, buffersrc, "in",
args, NULL, filter_graph);
if (ret < 0) {
av_log(NULL, AV_LOG_ERROR, "Cannot create audio buffer source\n");
goto end;
}
ret = avfilter_graph_create_filter(&buffersink_ctx, buffersink, "out",
NULL, NULL, filter_graph);
if (ret < 0) {
av_log(NULL, AV_LOG_ERROR, "Cannot create audio buffer sink\n");
goto end;
}
ret = av_opt_set_bin(buffersink_ctx, "sample_fmts",
(uint8_t*)&enc_ctx->sample_fmt, sizeof(enc_ctx->sample_fmt),
AV_OPT_SEARCH_CHILDREN);
if (ret < 0) {
av_log(NULL, AV_LOG_ERROR, "Cannot set output sample format\n");
goto end;
}
ret = av_opt_set_bin(buffersink_ctx, "channel_layouts",
(uint8_t*)&enc_ctx->channel_layout,
sizeof(enc_ctx->channel_layout), AV_OPT_SEARCH_CHILDREN);
if (ret < 0) {
av_log(NULL, AV_LOG_ERROR, "Cannot set output channel layout\n");
goto end;
}
ret = av_opt_set_bin(buffersink_ctx, "sample_rates",
(uint8_t*)&enc_ctx->sample_rate, sizeof(enc_ctx->sample_rate),
AV_OPT_SEARCH_CHILDREN);
if (ret < 0) {
av_log(NULL, AV_LOG_ERROR, "Cannot set output sample rate\n");
goto end;
}
} else {
ret = AVERROR_UNKNOWN;
goto end;
}
/* Endpoints for the filter graph. */
outputs->name = av_strdup("in");
outputs->filter_ctx = buffersrc_ctx;
outputs->pad_idx = 0;
outputs->next = NULL;
inputs->name = av_strdup("out");
inputs->filter_ctx = buffersink_ctx;
inputs->pad_idx = 0;
inputs->next = NULL;
if (!outputs->name || !inputs->name) {
ret = AVERROR(ENOMEM);
goto end;
}
if ((ret = avfilter_graph_parse_ptr(filter_graph, filter_spec,
&inputs, &outputs, NULL)) < 0)
goto end;
if ((ret = avfilter_graph_config(filter_graph, NULL)) < 0)
goto end;
/* Fill FilteringContext */
fctx->buffersrc_ctx = buffersrc_ctx;
fctx->buffersink_ctx = buffersink_ctx;
fctx->filter_graph = filter_graph;
end:
avfilter_inout_free(&inputs);
avfilter_inout_free(&outputs);
return ret;
}
static int init_filters(void)
{
const char *filter_spec;
unsigned int i;
int ret;
filter_ctx = av_malloc_array(ifmt_ctx->nb_streams, sizeof(*filter_ctx));
if (!filter_ctx)
return AVERROR(ENOMEM);
for (i = 0; i < ifmt_ctx->nb_streams; i++) {
filter_ctx[i].buffersrc_ctx = NULL;
filter_ctx[i].buffersink_ctx = NULL;
filter_ctx[i].filter_graph = NULL;
if (!(ifmt_ctx->streams[i]->codecpar->codec_type == AVMEDIA_TYPE_AUDIO
|| ifmt_ctx->streams[i]->codecpar->codec_type == AVMEDIA_TYPE_VIDEO))
continue;
if (ifmt_ctx->streams[i]->codecpar->codec_type == AVMEDIA_TYPE_VIDEO)
filter_spec = "null"; /* passthrough (dummy) filter for video */
else
filter_spec = "anull"; /* passthrough (dummy) filter for audio */
ret = init_filter(&filter_ctx[i], stream_ctx[i].dec_ctx,
stream_ctx[i].enc_ctx, filter_spec);
if (ret)
return ret;
}
return 0;
}
static int encode_write_frame(AVFrame *filt_frame, unsigned int stream_index, int *got_frame) {
int ret;
int got_frame_local;
AVPacket enc_pkt;
int (*enc_func)(AVCodecContext *, AVPacket *, const AVFrame *, int *) =
(ifmt_ctx->streams[stream_index]->codecpar->codec_type ==
AVMEDIA_TYPE_VIDEO) ? avcodec_encode_video2 : avcodec_encode_audio2;
if (!got_frame)
got_frame = &got_frame_local;
av_log(NULL, AV_LOG_INFO, "Encoding frame\n");
/* encode filtered frame */
enc_pkt.data = NULL;
enc_pkt.size = 0;
av_init_packet(&enc_pkt);
ret = enc_func(stream_ctx[stream_index].enc_ctx, &enc_pkt,
filt_frame, got_frame);
av_frame_free(&filt_frame);
if (ret < 0)
return ret;
if (!(*got_frame))
return 0;
/* prepare packet for muxing */
enc_pkt.stream_index = stream_index;
av_packet_rescale_ts(&enc_pkt,
stream_ctx[stream_index].enc_ctx->time_base,
ofmt_ctx->streams[stream_index]->time_base);
av_log(NULL, AV_LOG_DEBUG, "Muxing frame\n");
/* mux encoded frame */
ret = av_interleaved_write_frame(ofmt_ctx, &enc_pkt);
return ret;
}
static int filter_encode_write_frame(AVFrame *frame, unsigned int stream_index)
{
int ret;
AVFrame *filt_frame;
av_log(NULL, AV_LOG_INFO, "Pushing decoded frame to filters\n");
/* push the decoded frame into the filtergraph */
ret = av_buffersrc_add_frame_flags(filter_ctx[stream_index].buffersrc_ctx,
frame, 0);
if (ret < 0) {
av_log(NULL, AV_LOG_ERROR, "Error while feeding the filtergraph\n");
return ret;
}
/* pull filtered frames from the filtergraph */
while (1) {
filt_frame = av_frame_alloc();
if (!filt_frame) {
ret = AVERROR(ENOMEM);
break;
}
av_log(NULL, AV_LOG_INFO, "Pulling filtered frame from filters\n");
ret = av_buffersink_get_frame(filter_ctx[stream_index].buffersink_ctx,
filt_frame);
if (ret < 0) {
/* if no more frames for output - returns AVERROR(EAGAIN)
* if flushed and no more frames for output - returns AVERROR_EOF
* rewrite retcode to 0 to show it as normal procedure completion
*/
if (ret == AVERROR(EAGAIN) || ret == AVERROR_EOF)
ret = 0;
av_frame_free(&filt_frame);
break;
}
filt_frame->pict_type = AV_PICTURE_TYPE_NONE;
ret = encode_write_frame(filt_frame, stream_index, NULL);
if (ret < 0)
break;
}
return ret;
}
static int flush_encoder(unsigned int stream_index)
{
int ret;
int got_frame;
if (!(stream_ctx[stream_index].enc_ctx->codec->capabilities &
AV_CODEC_CAP_DELAY))
return 0;
while (1) {
av_log(NULL, AV_LOG_INFO, "Flushing stream #%u encoder\n", stream_index);
ret = encode_write_frame(NULL, stream_index, &got_frame);
if (ret < 0)
break;
if (!got_frame)
return 0;
}
return ret;
}
int main(int argc, char **argv)
{
int ret;
AVPacket packet = { .data = NULL, .size = 0 };
AVFrame *frame = NULL;
enum AVMediaType type;
unsigned int stream_index;
unsigned int i;
int got_frame;
int (*dec_func)(AVCodecContext *, AVFrame *, int *, const AVPacket *);
if (argc != 3) {
av_log(NULL, AV_LOG_ERROR, "Usage: %s <input file> <output file>\n", argv[0]);
return 1;
}
if ((ret = open_input_file(argv[1])) < 0)
goto end;
if ((ret = open_output_file(argv[2])) < 0)
goto end;
if ((ret = init_filters()) < 0)
goto end;
/* read all packets */
while (1) {
if ((ret = av_read_frame(ifmt_ctx, &packet)) < 0)
break;
stream_index = packet.stream_index;
type = ifmt_ctx->streams[packet.stream_index]->codecpar->codec_type;
av_log(NULL, AV_LOG_DEBUG, "Demuxer gave frame of stream_index %u\n",
stream_index);
if (filter_ctx[stream_index].filter_graph) {
av_log(NULL, AV_LOG_DEBUG, "Going to reencode&filter the frame\n");
frame = av_frame_alloc();
if (!frame) {
ret = AVERROR(ENOMEM);
break;
}
av_packet_rescale_ts(&packet,
ifmt_ctx->streams[stream_index]->time_base,
stream_ctx[stream_index].dec_ctx->time_base);
dec_func = (type == AVMEDIA_TYPE_VIDEO) ? avcodec_decode_video2 :
avcodec_decode_audio4;
ret = dec_func(stream_ctx[stream_index].dec_ctx, frame,
&got_frame, &packet);
if (ret < 0) {
av_frame_free(&frame);
av_log(NULL, AV_LOG_ERROR, "Decoding failed\n");
break;
}
if (got_frame) {
frame->pts = frame->best_effort_timestamp;
ret = filter_encode_write_frame(frame, stream_index);
av_frame_free(&frame);
if (ret < 0)
goto end;
} else {
av_frame_free(&frame);
}
} else {
/* remux this frame without reencoding */
av_packet_rescale_ts(&packet,
ifmt_ctx->streams[stream_index]->time_base,
ofmt_ctx->streams[stream_index]->time_base);
ret = av_interleaved_write_frame(ofmt_ctx, &packet);
if (ret < 0)
goto end;
}
av_packet_unref(&packet);
}
/* flush filters and encoders */
for (i = 0; i < ifmt_ctx->nb_streams; i++) {
/* flush filter */
if (!filter_ctx[i].filter_graph)
continue;
ret = filter_encode_write_frame(NULL, i);
if (ret < 0) {
av_log(NULL, AV_LOG_ERROR, "Flushing filter failed\n");
goto end;
}
/* flush encoder */
ret = flush_encoder(i);
if (ret < 0) {
av_log(NULL, AV_LOG_ERROR, "Flushing encoder failed\n");
goto end;
}
}
av_write_trailer(ofmt_ctx);
end:
av_packet_unref(&packet);
av_frame_free(&frame);
for (i = 0; i < ifmt_ctx->nb_streams; i++) {
avcodec_free_context(&stream_ctx[i].dec_ctx);
if (ofmt_ctx && ofmt_ctx->nb_streams > i && ofmt_ctx->streams[i] && stream_ctx[i].enc_ctx)
avcodec_free_context(&stream_ctx[i].enc_ctx);
if (filter_ctx && filter_ctx[i].filter_graph)
avfilter_graph_free(&filter_ctx[i].filter_graph);
}
av_free(filter_ctx);
av_free(stream_ctx);
avformat_close_input(&ifmt_ctx);
if (ofmt_ctx && !(ofmt_ctx->oformat->flags & AVFMT_NOFILE))
avio_closep(&ofmt_ctx->pb);
avformat_free_context(ofmt_ctx);
if (ret < 0)
av_log(NULL, AV_LOG_ERROR, "Error occurred: %s\n", av_err2str(ret));
return ret ? 1 : 0;
}</code></pre><p>代码核心流程介绍:</p><ul><li>调用open_input_file打开输入文件,先调用avformat_open_input进行解封装,然后调用avformat_find_stream_info判断是否存在音视频流信息,如果存在,则根据video/audio stream的codec id,调用avcodec_find_decoder->avcodec_alloc_context3->avcodec_open2完成音视频解码器的创建</li><li>接着调用open_output_file,先调用avformat_alloc_output_context2创建mux的上下文,然后根据input stream info,调用avformat_new_stream创建对应的输出流,接着再调用avcodec_find_encoder->avcodec_alloc_context3->avcodec_open2创建对应的编码器(上述例子中,mux output stream codec id跟输入是保持一致,实际转码可以不一致)</li><li>不管是input AVStream还是output AVStream,它们跟对应的编解码器(AVCodecContext)其实是没有强关联的,但是在创建编解codec之前,需要调用avcodec_parameters_from_context,将stream->codecpar和AVCodecContext中编解码器的配置参数做下同步</li><li>调用init_filters创建滤镜(滤镜的创建下面再介绍)</li><li>调用av_read_frame读取待解码的原始帧数据,接着调用avcodec_decode_video2/avcodec_decode_audio4进行解码</li><li>如果存在filter graph,则将解码后的AVFrame传入filter graph添加滤镜</li><li>再调用avcodec_encode_video2/avcodec_encode_audio2对数据帧进行编码</li><li>编码完成后,再调用av_packet_rescale_ts设置AVPacket的time stamp</li><li>最后调用av_interleaved_write_frame写入output file中</li></ul><h3>帧数据存储</h3><p>ffmpeg使用AVPacket来存储编码的帧数据,解码后的音视频帧数据,统一使用AVFrame来存储。</p><p>音频帧数据存储音频解码后的pcm数据,在ffmpeg内部有Packed和Planar两种存储方式:</p><ul><li>Packed: L R L R L R L</li><li>RPlanar: L L L L R R R</li></ul><p>RPacked格式,frame.data[0]或frame.extended_data[0]包含AVFrame保存的所有pcm数据</p><p>Planar格式,frame.data[i]或者frame.extended_data[i]表示第i个声道的数据</p><p>AVFrame.data数组大小固定为8,如果声道数超过8,需要从frame.extended_data获取声道数据, extended_data是为了支持更多的声道数,后期扩展的字段。</p><p>Planar是ffmpeg内部的数据格式,常规的都为Packed,从命名上,Planar一般都在Packed命名后+P,比如sample format为16bit,Packed命名:AV_SAMPLE_FMT_S16,Planar:AV_SAMPLE_FMT_S16P。</p><p>除了data/extended_data,AVFrame保存音频数据其他四个核心字段:format(AVSampleFormat),sample_rate,channel_layout, nb_samples。<br>format - 指的是单帧的存储格式,可以通过该值来确定是packed还是planar,以及存储大小,16bit或float等<br>sample_rate - 采样率<br>channel_layout - 声道数<br>nb_samples - AVFrame中包含的采样数(单声道)</p><p>所以,我们通过sample_rate和nb_samples就可以得出AVFrame包含pcm数据的播放时长,通过format <em> channel_layout </em> nb_samples就可以得出AVFrame中buffer的长度</p><p><img src="/img/remote/1460000044587976" alt="图片" title="图片"></p><p>**视频帧数据存储</p><p>**视频帧数据采用YUV格式,其中Y指亮度通道,UV指色度通道,主流的采样格式有:YUV444、YUV422、YUV420<br>YUV4:<em>:</em>, 简单点理解就是,以4个像素为一采样组,每个像素固定有一个Y通道,YUV后面UV对应的数值,以2为单位对应一组UV色度通道,注意,UV是一组,不要将其分开,基于这个去理解YUV4:2:2和YUV4:2:0采样方式,会更容易点<br>对于大分辨率的视频帧,相邻像素的色度通道差异是极小的,所以YUV420格式在保证图像质量的情况下,又大幅的降低了存储,是目前主流的帧格式,接下去重点介绍下YUV420在ffmpeg,即AVFrame里的存储,YUV420根据Y,U,V数据的存储方式,又细分出YUV420P,YUV420SP(NV12)等子格式</p><p>YUV420P: YYYYYYYY UU VV<br>Y分量、U分量、V分量分别占一个平面空间,4个像素的Y分量共用一个UV分量</p><p><img src="/img/remote/1460000044587977" alt="图片" title="图片"></p><p>YUV420SP: YYYYYYYY UU VV<br>Y分量占一个平面空间,UV交差存储占一个平面空间,4个像素的Y分量共用一个UV分量</p><p><img src="/img/remote/1460000044587978" alt="图片" title="图片"></p><p>二者的差异,就是UV分量的存储方式,AVFrame保存YUV数据,主要用</p><pre><code>uint8_t *data[AV_NUM_DATA_POINTERS];
int linesize[AV_NUM_DATA_POINTERS];</code></pre><p>data保存平面对应的向量数据,linesize保存平面对应向量数据的长度,所以yuv420p有三个平面,data数组有效长度为3,yuv420sp只有两个平面,有效长度就只有2。</p><h3>时间基 - TimeBase</h3><p>ffmpeg音视频处理,有一个很重要的概念,那就是时间基,时间基本质是时间刻度,ffmpeg内部用到的时间戳都是要与对应的time base换算才能拿到准确时间的,ffmpeg内部主要有三种类型的time base</p><ul><li>tbr - 表示帧率,tbr往往跟fps相同,比如帧率25,time base即为 1/25</li><li>tbn - 表示视频流(AVStream) 的timebase,在解码时,从视频source demux后,可以拿到video container的stream time base,作为后续视频解码和seek等操作时的基准时间;在编码时,AVPacket在写入文件前,必须要正确的设置AVStream的time base和pts,要不编码完成的视频播放会异常</li><li>tbc - 表示视频流 codec timebase,编码时,需要设置codec context的time base</li></ul><p>有了时间基和时间戳,可以很容易计算出对应的时间,比如时间戳50,时间基1/25<br><code>50 * 1 / 25 = 2s</code></p><p>ffmpeg也提供了辅助计算函数:<br><code>timestamp(秒) = pts * av_q2d(time_base)</code></p><p>不同时间基之间换算:<br><code>av_rescale_q</code></p><p>对AVPacket内部时间基的换算:<br><code>av_packet_rescale_ts</code></p><h3>滤镜 - Filter</h3><p>ffmpeg内部filter graph处理流程</p><p><img src="/img/remote/1460000044587979" alt="图片" title="图片"></p><p>filter graph建立后,会监听source filter的source buffer,如果有AVFrame塞入,filter graph就开始运作,通过filter chain处理完后,从sink filter的sink buffer中取出,格式为AVFrame。</p><p>上述工程里initFilter代码流程:</p><ul><li>先调用avfilter_graph_alloc创建filter graph</li><li>接着调用avfilter_get_by_name创建命名的filter</li><li>接着基于创建的filter,调用avfilter_graph_create_filter创建AVFilterContext,上下文会关联filter和filter graph等其他信息</li><li>最后调用avfilter_graph_parse_ptr,将通过filter spec创建的filter以及咱们创建的input和output filter插入到filter graph</li></ul><p>然后在帧处理的时候:</p><ul><li>调用av_buffersrc_add_frame_flags将待处理帧塞入buffersrc_ctx,触发filter graph开发工作</li><li>接着调用av_buffersink_get_frame从buffersink_ctx处理完成的帧数据</li></ul><p>步骤2和3创建了source和sink filter,work filter则是使用avfilter_graph_parse_ptr传入filter spec,ffmpeg会解析spec创建内置的filter,当然,我们也可以实现自定义的filter塞入到filte graph中。</p><p>下面是添加水印的filter spec:</p><pre><code>static const char* filters[] = {
"movie=/sdcard/0/pet_logo.png[watermark];[in][watermark]overlay=main_w-overlay_w-10:main_h-overlay_h-10[out]"};</code></pre><p>filter spec更多介绍,可以去官网查看:<a href="https://link.segmentfault.com/?enc=F7ZuKVMzVbH6CrvNRDkpuw%3D%3D.hSv85n3Ea4agCRUMU6s8VICzK833sw0MqD8UikpR2pIwRviPYjXkTSqAnhzlq8hE" rel="nofollow">https://ffmpeg.org/ffmpeg-filters.html</a></p><p>(本文作者:胡付义)</p><p><img width="732" height="246" src="https://segmentfault.com/img/bVdajSo" alt="图片" title="图片"></p>
年度重磅|2023哈啰技术精选电子书下载
https://segmentfault.com/a/1190000044570037
2024-01-19T13:06:29+08:00
2024-01-19T13:06:29+08:00
哈啰技术
https://segmentfault.com/u/hellotech
0
<p>年轮依旧,时光匆匆,2024 甲辰龙年正悄然向我们走来。</p><p>在新春到来之际,我们为大家盘点过去一年的精选文章,整理制作成一本近 300 页,6 万字的电子书。</p><p>电子书的内容覆盖后端、前端、算法、运维、质量等不同领域,每一篇都干货满满,希望对各位同学拓展技术思路有所帮助。</p><p>感谢各位同学一直以来的支持,也欢迎大家将「哈啰技术」推荐给身边感兴趣的朋友。</p><p>愿大家的2024,龙行龘龘,前程朤朤。</p><p><img width="723" height="1038" src="/img/bVdbaRI" alt="image.png" title="image.png"></p><h3>获取方式:</h3><p>关注「哈啰技术」微信公众号,回复关键词:2023精选,即可获取电子书的下载链接。</p>
React hooks原理浅谈
https://segmentfault.com/a/1190000044535941
2024-01-08T15:40:11+08:00
2024-01-08T15:40:11+08:00
哈啰技术
https://segmentfault.com/u/hellotech
0
<h2>react的工作流程</h2><p>fiber是react的基本工作单元,所有的操作都要基于它实现。其实fiber就类似一个个element元素,react的工作流程其实就是遍历fiber tree。</p><p><img src="/img/remote/1460000044535943" alt="图片" title="图片"></p><p><img src="/img/remote/1460000044535944" alt="图片" title="图片"></p><p>performUnitOfWork函数会执行当前的fiber节点,然后把这个fiber的子节点赋值给workInProgress,当子节点不存在时,就把兄弟节点赋值给workInProgress。</p><p>上层的workLoopSync函数的 while循环会根据下个workInProgress去遍历。这样就能实现一个深度优先遍历,从而把所有的fiber执行完毕。</p><p>在performUnitOfWork函数中分为两个阶段:<br>1.beginWork</p><ul><li>执行render函数以及hook,然后返回jsx</li><li>对返回的jsx执行diff,如果有新的fiber节点生成则赋值给workInProgress继续迭代</li></ul><p>2.completeWork回溯fiber tree</p><ul><li>生成dom节点,组成一个虚拟dom树</li><li>处理props</li><li>把所有含有副作用的fiber节点用firstEffect和lastEffect链接起来,组成一个链表,然后在commit阶段遍历执行</li></ul><p>在completeWork执行到根节点时,证明所有的工作已经完成,就会执行commitRoot,它又分为三个阶段:<br>1.before mutation(执行dom操作前)<br>调用挂载前的生命周期钩子,比如getSnapshotBeforeUpdate,调度useEffect</p><p>2.mutation(执行dom操作)<br>执行dom操作,如果有组件被删除,那么还会调用componentWilUnmount或useLayoutEffect的销毁函数</p><p>3.layout(执行dom操作后)</p><ul><li>切换fiber tree</li><li>调用componentDidUpdate、componentDidMount或者useLayoutEffect的回调函数。</li><li>layout结束后,执行之前调度的useEffect的创建和销毁函数。</li></ul><p>接下来我们重点看下hook的实现。</p><h2>useState不同阶段调用的方法不同</h2><p><img src="/img/remote/1460000044535945" alt="图片" title="图片"></p><p><img src="/img/remote/1460000044535946" alt="图片" title="图片"></p><p>因而useState在mount时实际上调用的是mountState方法,update时调用的是updateState方法(updateState是updateReducer的语法糖写法)。</p><p><img src="/img/remote/1460000044535947" alt="图片" title="图片"></p><p>当mount阶段依次调用hook时,第一个生成的hook是挂在当前组件节点(reactFiber节点)的memoizedState属性上,之后生成的hook则依次挂在上一个hook的next属性上。</p><p><img src="/img/remote/1460000044535948" alt="图片" title="图片"></p><p><img src="/img/remote/1460000044535949" alt="图片" title="图片"></p><p>所以当我们将hook置于循环、条件语句、嵌套函数中时,那么hook链表就会错乱,会导致hook调用顺序不可预测,那就没法保证组件内部状态一致性。当我们setState时会返回一个初始state和用于更新state的函数。</p><p><img src="/img/remote/1460000044535950" alt="图片" title="图片"></p><p>我们知道mount阶段useState调用的是mountState,查看源码后知道返回的其实是[hook.memoizedState, dispatch]。</p><p><img src="/img/remote/1460000044535951" alt="图片" title="图片"></p><p>dispatch其实就是dispatchSetState通过bind到当前组件节点、更新队列后的函数。</p><p><img src="/img/remote/1460000044535952" alt="图片" title="图片"></p><p>假设执行了3次setOrder,分别是 setOrder('1')、setOrder('2')、setOrder('3')。</p><pre><code>if (pending === null) {
// This is the first update. Create a circular list.
update.next = update;
} else {
update.next = pending.next;
pending.next = update;
}
queue.pending = update;</code></pre><p>setOrder('1')时</p><p><img src="/img/remote/1460000044535953" alt="图片" title="图片"></p><p>setOrder('2') 时</p><p><img src="/img/remote/1460000044535954" alt="图片" title="图片"></p><p>setOrder('3')时</p><p><img src="/img/remote/1460000044535955" alt="图片" title="图片"></p><p>上面说过updte阶段实际调用的是updateReducer方法,这个方法中主要做了这几件事</p><ul><li>如果有新的更新还未处理,则加入当前更新链表中</li><li>清空待更新链表(queue.pending = null)</li><li>从待更新链表的第一个循环迭代更新,直到最后一个</li><li>更新当前hook状态值并return出去</li></ul><p><img src="/img/remote/1460000044535956" alt="图片" title="图片"></p><p>通过setOrder(1)、setOrder(2)、setOrder(3)的图例我们知晓 queue.pending.next 即更新链表的总是指向第一个update,而queue.pending总是指向最后一个。</p><p>一开始将update(1)赋值给update,然后获取newState也就是1,接下来update=update.next,此时update成了update(2),依次遍历,终止条件为update === null || update === first,也就是当update = update(3)时满足了终止条件,此时newState = 3,取到了最新值。这样可以保证整个update链表都循环了一遍同时取到的是链表中的最后一个节点。所以无论setState多少次,拿到的总是最新的值(问题2)。</p><h2>useEffect不同阶段调用的方法不同</h2><p>mount阶段,useEffect调用的是mountEffect,update阶段,useEffect调用的是updateEffect函数。</p><p><img src="/img/remote/1460000044535957" alt="图片" title="图片"></p><p>无论useEffect的依赖项是否相同都会调用pushEffect函数,唯一区别的是pushEffect函数的第一个参数是不同的,如果依赖项没有变化则第一个参数是hookFlags,反之则是HookHasEffect|hookFlags(标识存在副作用更新钩子)。</p><p><img src="/img/remote/1460000044535958" alt="图片" title="图片"></p><p><img src="/img/remote/1460000044535959" alt="图片" title="图片"></p><p><img src="/img/remote/1460000044535960" alt="图片" title="图片"></p><p><img src="/img/remote/1460000044535961" alt="图片" title="图片"></p><p>pushEffect主要做两件事:</p><ul><li>创建 effect 对象并返回</li><li>把这个 effect 链接到 currentlyRenderingFiber的updateQueue属性上</li></ul><p>结论:useEffect会生成一个effect对象,保存在hook节点的memoizedState中,同时也更新到currentlyRenderingFiber的updateQueue中,组成循环链表。每次render时,都会对比一下新旧hook里保存的effect的deps有没有改变,如果改变了,那就更新memoizedState为最新的effect,并且把effect的tag标识为存在副作用,然后currentlyRenderingFiber的updateQueue属性里。在commit阶段,beforeMutation中,对有副作用的fiber,发起一个异步调度。等到layout结束后,这个异步调度的回调开始执行,处理effect的创建和销毁回调。它会先调用effect的destroy,再调用create。</p><p>(本文作者:尚军平)</p><p><img width="732" height="246" src="https://segmentfault.com/img/bVdajSo" alt="图片" title="图片"></p>
前端单元测试入门实践
https://segmentfault.com/a/1190000044522849
2024-01-03T11:30:18+08:00
2024-01-03T11:30:18+08:00
哈啰技术
https://segmentfault.com/u/hellotech
1
<h2>前端单元测试</h2><p>简介前端单元测试是指对前端代码中的最小可测试单元进行测试的过程。这些最小单元可以是函数、组件或模块等。通过编写针对这些单元的测试用例,我们可以验证它们在各种情况下的行为是否符合预期。</p><p>单元测试从概念上可以分为:</p><ul><li>TDD (Test-Driven Development):测试驱动开发</li><li>BDD (Behavior-Driven Development):行为驱动开发</li></ul><h3>TDD</h3><p>TDD 强调在编写实际代码之前先编写测试用例。TDD的核心理念是通过编写测试来指导代码的开发,以确保代码的正确性和可靠性。TDD的工作流程通常包括以下步骤:</p><ul><li>编写测试用例:首先,开发者根据需求或功能规范编写一个测试用例。这个测试用例描述了期望的行为和结果。初始时,由于尚未编写任何实际代码,所以测试用例会失败。</li><li>运行测试用例:接下来,开发者运行测试用例,确认它们失败。这是因为尚未编写与测试用例相匹配的实际代码。</li><li>编写实际代码:在测试用例失败的基础上,开发者开始编写实际的代码,以满足测试用例中所描述的需求。目标是使得测试用例能够通过并成功执行。</li><li>运行测试用例并重构:一旦实际代码编写完成,开发者再次运行测试用例。如果测试用例通过,说明代码已经正确实现了需求。此时,开发者可以进行代码重构,改进代码的结构、可读性和性能,同时确保测试用例仍然通过。</li></ul><h3>编写单元测试的意义</h3><ul><li>提高代码质量:通过测试用例覆盖率和错误检测,我们可以及早发现并修复潜在的问题,从而提高代码的质量。</li><li>简化调试过程:当出现问题时,单元测试可以帮助我们快速定位错误所在,并且在修复后能够确保不会再次出现同样的问题。</li><li>支持重构和维护:在进行代码重构或修改时,单元测试可以帮助我们确保改动不会破坏原有功能。</li><li>促进团队协作:通过编写单元测试,团队成员可以更好地理解彼此的代码,并共享对代码行为的理解,从而促进团队协作。</li></ul><h2>如何进行单元测试</h2><h3>简单尝试</h3><p>第一步,用你最喜欢的包管理工具安装测试框架Jest。<br><code>npm install --save-dev jest</code></p><p>我们现在可以写一个简单的demo来试试水,找个空地创建名为sum.js的文件。</p><pre><code>function sum(a, b) {
return a + b;
}
module.export = sum;</code></pre><p>显然这是个提供计算两数之和(或拼接字符串)能力的模块。如果不使用单元测试框架,我们会通过把代码跑起来打日志这种朴素的方法去验证它的正确性,现在我们可以创建sum.test.js文件来对其进行验证。</p><pre><code>const sum = require('./sum');
describe('sum.js', () => {
test('两数之和', () => {
expect(sum(1, 2)).toBe(3);
});
it('字符串拼接', () => {
expect(sum('a', 'b')).toBe('ab');
});
});</code></pre><p>在package.json中添加</p><pre><code>{
"scripts": {
"test": "jest"
}
}</code></pre><p>运行<br><code>npm run test</code></p><p>如果一切正常,Jest将输出如下信息:</p><p><img src="/img/remote/1460000044522851" alt="图片" title="图片"></p><p>测试文件由许多测试用例组成,Jest通过test方法(或与其等价的it方法)执行测试代码,在上述demo中,我们编写了两个测试用例,验证了sum方法的正确性。</p><h4>支持ES6语法</h4><p>Jest是执行在Node环境的,Node.js采用CommonJS模块化规范,通过require引入模块,如果要使用import这类ES6模块化规范的语法,必须借助babel来转义支持。</p><p>安装babel相关依赖</p><pre><code>npm install --save-dev @babel/core @babel/preset-env</code></pre><p>在根目录下配置babel,创建.babelrc</p><pre><code>{
"presets": [
"@babel/preset-env"
]
}</code></pre><p>现在我们可以自由使用ES6的模块化语法了。</p><pre><code>function sum(a, b) {
return a + b;
}
export sum;</code></pre><pre><code>import { sum } from './sum';
describe('sum.js', () => {
test('两数之和', () => {
expect(sum(1, 2)).toBe(3);
});
test('字符串拼接', () => {
expect(sum('a', 'b')).toBe('ab');
});
});</code></pre><p>原因是Jest运行时内部会先执行jest-babel检测是否安装babel-core,然后获取.babelrc中的配置,在运行测试文件前用babel将其转换为支持的语法再执行测试。</p><h4>使用TypeScript</h4><p>如果要对TypeScript文件进行单元测试,需要额外安装依赖,借助babel去解析TypeScript文件再进行测试。</p><pre><code>npm install --save-dev @babel/preset-typescript</code></pre><p>在.babelrc中添加对应配置</p><pre><code>{
"presets": [
"@babel/preset-env",
"@babel/preset-typescript"
]
}</code></pre><p>如果使用如VSCode之类的编辑器,为了解决其对Jest断言方法类型的报错,还需额外安装@types中的第三方类型库。<br><code>npm install --save-dev @types/jest</code></p><h3>目录结构</h3><p>测试文件一般以.test.js/ts为尾缀,通常放在根目录下的test文件夹内。</p><p><img src="/img/remote/1460000044522852" alt="图片" title="图片"></p><p>不过现在更推荐放在被测试的源代码旁边,这样做有很多好处,其一是方便查找,其二是方便管理,当废弃某个组件或模块是,与其有关的所有垃圾文件(包括测试代码)方便一并废弃。</p><p><img src="/img/remote/1460000044522853" alt="图片" title="图片"></p><h3>简单配置</h3><p>尽管Jest做到了开箱即用,但有时我们仍想自定义某些配置,此时可以通过执行。</p><pre><code>## 全局安装时
jest --init
## 局部安装时
npx jest --init</code></pre><p>生成基础配置。每个字段的具体含义,可以参阅官方文档。</p><h4>测试覆盖率</h4><p>单元测试的覆盖率是指所有功能代码中完成单元测试的代码所占的比例,可以反映测试用例的全面性与被测试代码的可靠性。测试覆盖率由语句覆盖率、函数覆盖率、分支覆盖率和行覆盖率所量化。</p><ul><li>Statements(语句覆盖率)<br>是否每个语句都执行了</li><li>Branches(分支覆盖率)<br>是否每个判断都执行了</li><li>Functions(函数覆盖率)<br>是否每个函数都执行了</li><li>Lines(行覆盖率)<br>是否每行都执行了</li></ul><p>我们可以通过如下配置来查看每个测试用例的测试覆盖率以及自定义覆盖率阈值。</p><pre><code>module.exports = {
// 是否显示测试覆盖率报告
collectCoverage: true,
// 哪些文件需要被执行单元测试
collectCoverageFrom: [
'src/utils/**/*'
],
extensionsToTreatAsEsm: ['.tsx', '.jsx', '.ts'],
// 测试覆盖阈值
coverageThreshold: {
global: {
statements: 90, // 保证90%的语句都执行了
functions: 90, // 保证90%的函数都调用了
branches: 60, // 保证60%的 if 等分支代码都执行了
},
},
}</code></pre><h2>常见测试场景</h2><h3>匹配</h3><h4>toBe与toEqual</h4><p>两者都是用来验证相等的断言,toBe常用来比较值是否相等,toEqual常用来比较引用类型是否等价,按照官方解释,toEqual会递归对比对象实例的所有属性,因此也被称作深度相等。</p><pre><code>test('相等', () => {
const foo = { bar: 1 };
expect(foo.bar).toBe(1); // 通过
expect(foo).toBe({ bar: 1 }); // 不通过
expect(foo).toEqual({ bar: 1 }); // 通过
});</code></pre><h4>not</h4><p>not修饰符用来表示取反,用在其他断言之前,比如</p><pre><code>test('not', () => {
const foo = 1;
expect(foo).not.toBe(2); // 通过
});</code></pre><h4>toMatch</h4><p>toMatch允许我们传入一个正则表达式,用来精确匹配字符串。</p><pre><code>test('match', () => {
const foo = 'hello jest';
expect(foo).toMatch(/hello/i); // 通过
});</code></pre><h4>toContain</h4><p>toContain用来检测对象中是否包含某个值。</p><pre><code>test('contain', () => {
const data = ['foo', 'bar'];
expect(data).toContain('fo'); // 不通过
});</code></pre><h4>其他匹配器</h4><ul><li>toBeNull:是否 null</li><li>toBeUndefined:是否 undefined</li><li>toBeDefined:是否定义</li><li>toBeTruthy:是否为真</li><li>toBeFalsy:是否为假</li><li>toBeGreaterThan 大于</li><li>toBeGreaterThanOrEqual 大于等于</li><li>toBeLessThan 小于</li><li>toBeLessThanOrEqual 小于等于</li><li>toBeCloseTo 匹配浮点数</li></ul><h3>函数</h3><h4>测试异常</h4><p>可以使用toThrow来测试函数执行过程中是否抛出错误,需要注意的是,在Jest中我们必须对被测试函数再做一层包装才有效。</p><pre><code>function onlyNumber(param) {
if (typeof param !== 'number') {
throw Error('Only Number!');
}
return param;
}
test('throw', () => {
expect(onlyNumber('1')).toThrow('Only Number!'); // 通过
});</code></pre><h4>测试回调</h4><p>关键是需要手动调用done()。</p><pre><code>function getName(callback) {
new Promise((resolve) => {
setTimeout(() => {
resolve('bar');
}, 1000);
}).then((res) => {
callback(res);
});
}
describe('test callback', () => {
// 用例1, 错误用法
test('fault', () => {
getName((res) => {
expect(res).toBe('foo'); // 通过
});
});
// 用例2, 正确用法
test('right', (done) => {
getName((res) => {
expect(res).toBe('foo'); // 不通过
done();
});
});
});</code></pre><p>在错误的用例1中,无论断言是toBe什么,只要被测试函数本身不报错,测试用例都会通过,因为回调函数本身并未执行。</p><p><img src="/img/remote/1460000044522854" alt="图片" title="图片"></p><p>需要在回调函数中手动调用done(),表示该回调函数执行以后,用例才算通过。</p><p><img src="/img/remote/1460000044522855" alt="图片" title="图片"></p><h4>测试异步</h4><p>这块测试我们可以通过使用 async 和 await 关键字来做到和写法和同步代码基本一致(推荐),也可以使用jest官方提供的resolves和rejects修饰符,当然,使用.then之类的链式写法也可以。后两种方法记得return。</p><pre><code>// 模拟一个异步操作
function getName() {
return new Promise((resolve) => {
setTimeout(() => {
resolve('bar');
}, 1000);
});
}
describe('test promise', () => {
// 写法1
test('style1', async () => {
const res = await getName();
expect(res).toBe('bar');
});
// 写法2
test('style2', () => {
return expect(getName()).resolves.toBe('bar');
});
// 写法3
test('style3', () => {
return getName().then((res) => {
expect(res).toBe('bar');
})
});
});</code></pre><p>如果在测试时发现jest不认识async、await等关键字,那就需要加强下babel,首先安装 @babel/plugin-transform-runtime。</p><pre><code>npm install --save-dev @babel/plugin-transform-runtime</code></pre><p>配置babel</p><pre><code>{
"plugins": ["@babel/plugin-transform-runtime"]
}</code></pre><h3>Mock</h3><h4>mock函数</h4><p>mock函数常用于被测试内部结构复杂的函数的运行是否符合预期,mock函数就是jest提供的一个被监控的函数,函数的参数、返回值、函数体、被调用次数等一切属性都可以自定义或观察。</p><pre><code>// 创建 mock 函数
const func = jest.fn();
// mock 函数返回值, 不 mock 默认 undefined
func.mockReturnValue(1);
// mock 函数返回值, 只生效一次,需要多次使用,方便 mock 不同情况下的返回值
func.mockReturnValueOnce(1);
// mock 函数实现
func.mockImplementation((params) => {
console.log('func be called with', params);
});
// mock 函数实现,只生效一次
func.mockImplementationOnce(() => {});
// mock 函数被调用次数
console.log(func.mock.calls.length);
// mock 函数第 i + 1 次被调用时所接受的实参
let i = 2;
console.log(func.mock.calls[i]);
// mock 函数第 i + 1 次被调用时内部的 this 指向
console.log(func.mock.instances[i]);</code></pre><p>具体的例子在mock定时器一节中。</p><h4>mock定时器</h4><p>在实际的被测试代码中,可能会遇到代码中存在计时器的延时操作。假如这个时间是30s,那么我们在对这块代码做测试时一定不想等待30s。因此,jest提供了一些与计时器有关的API来帮助我们跳过或加速时间。</p><pre><code>// 被测试代码
function handleData(callback) {
setTimeout(() => {
callback('bar');
seTimeout(() => {
callback('foo');
}, 1000);
}, 30 * 1000);
}
// 测试代码
// 每个测试用例都 mock 一个定时器, 避免串扰
beforeEach(() => { // beforeEach是jest提供的生命周期钩子, 每个测试用例执行前触发
jest.useFakeTimers(); // 一定要在 mock 计时器前进行声明
});
describe('test mock timer', () => {
test('runAllTimers', () => {
// mock 一个 callback 来用,
// 因为在本次测试中我们只关心计时器, 具体的内部操作我们不关心,
// 因此很适合用mock函数, 这样做的好处是假如内部是个耗时操作, 我们可以直接规避
const fn = jest.fn();
// 执行被测试代码
handleData(fn);
// 一次性执行完所有计时器
jest.runAllTimers();
// 回调执行了2次
expect(fn.mock.calls.length).toBe(2); // 通过
// 2次实参分别是'bar'和'foo'
expect(fn.mock.calls).toEqual(['bar', 'foo']); // 通过
});
test('runOnlyPendingTimers', () => {
const fn = jest.fn();
handleData(fn);
// 仅执行处于消息队列中的计时器
jest.runOnlyPendingTimers();
// 显然回调只会执行外层的一次
expect(fn.mock.calls.length).toBe(1); // 通过
});
test('advanceTimersByTime', () => {
const fn = jest.fn();
handleData(fn);
// 快进30s, 执行完外层计时器
jest.advanceTimersByTime(30 * 1000);
expect(fn.mock.calls.length).toBe(1); // 通过
// 快进1s, 执行完内层计时器
jest.advanceTimersByTime(1000);
expect(fn.mock.calls.length).toBe(2); // 通过
});
});</code></pre><h4>mock模块</h4><p>我们在代码中总是会引用到第三方模块,如果我们要测试的某一段代码依赖某个第三方模块,我们此时就可以mock它。最常见的例子就是网络请求。要mock一个模块,我们可以在被测模块的同级目录下创建一个__mocks__文件夹,并且在该文件下创建同名的mock模块;也可以不用这么做,直接在测试文件中导入原有模块,用jest.mock来mock想mock的模块。</p><pre><code>// 被mock模块
import axios from 'axios';
function fetchData1() {
return axios.get('https://xxx.xxx.xxx/xxx', res => res.data);
}
function fetchData2() {
return axios.get('https://xxx.xxx.xxx/xxx', res => res.data);
// 预计返回
// { type: '5G' }
}
export { fechData1, fetchData2 };</code></pre><pre><code>// mock 指定路径下的模块
jest.mock('./service', () => {
// 导入真实模块内容
const actualModules = jest.requireActual('./service');
// 混入 mock 内容
return {
...actualModules,
fetchData1: jest.fn(() => {
return new Promise((resolve, reject) => {
resolve({
foo: 'bar',
});
});
}),
};
});
// 此时导入的 fetchData1 是我们 mock 的, fetchData2 是原有的
import { fetchData1, fetchData2 } from './service';
describe('test mock modules', () => {
test('fetchData1', async () => {
const res = await fetchData1();
expect(res).toEqual({foo: 'bar'}); // 通过
});
test('fetchData2', async () => {
const res = await fechData2();
expect(res).toEqual({type: '5G'}); // 预期通过
});
});</code></pre><p>(本文作者:黄成翰)</p><p><img width="732" height="246" src="https://segmentfault.com/img/bVdajSo" alt="图片" title="图片"></p>
Taro编译mini-runner包的作用
https://segmentfault.com/a/1190000044504551
2023-12-26T15:30:09+08:00
2023-12-26T15:30:09+08:00
哈啰技术
https://segmentfault.com/u/hellotech
0
<h2>什么是Taro</h2><p>Taro 是一套遵循多端开发的解决方案。只需要一套代码,就可以编译转换成 RN、H5、小程序、快应用多端的运行代码,其运转流程主要分为编译时,运行时两个阶段。</p><p>Taro2(重编译,轻运行)</p><ul><li>编译时:通过taro工具将Taro源代码转换成目标代码</li><li>运行时:目标代码运行时,通过运行时的库去适配不同端</li></ul><p><img src="/img/remote/1460000044504553" alt="图片" title="图片"></p><p>Taro3(轻编译,重运行)<br>Taro3主要通过在小程序端模拟实现 DOM、BOM API 来让前端框架直接运行在小程序环境中,而对于生命周期、组件库、API、路由等差异,通过定义统一标准,在运⾏时会提供 React 和 Vue 对应的适配器进⾏适配,然后调⽤Taro提供的 DOM 和 BOM API, 最后把整个程序渲染到所有的⼩程序端上⾯。</p><p><img src="/img/remote/1460000044504554" alt="图片" title="图片"></p><h2>编译流程</h2><p>Taro本质上是进行多端编译和运行,而Taro的编译主要依赖于webpack配置,Taro则通过mini-runner完成webpack配置的组装,然后根据 webpack 配置生成编译后的代码。</p><h3>tarojs/mini-runner的作用</h3><ul><li>负责根据开发者的编译配置调整 webpack 配置</li><li>注入自定义的 插件 和 loader</li><li>调用 webpack 开启编译</li><li>修改 webpack 的编译产物,调整最终的编译结果</li></ul><p><img src="/img/remote/1460000044504555" alt="图片" title="图片"></p><h3>目录结构</h3><p><img src="/img/remote/1460000044504556" alt="图片" title="图片"></p><h3>主流程(index)</h3><p>主要分为两个流程,根据项目配置生产webpack的构建配置,利用webpack进行代码编译。</p><p><img src="/img/remote/1460000044504557" alt="图片" title="图片"></p><h3>基础配置</h3><p>base.config.ts 文件记录基本配置信息,主要是Taro 构建过程中一部分 webpack 配置的初始化工作(包括文件扩展名、模块解析路径、别名等)。</p><p><img src="/img/remote/1460000044504558" alt="图片" title="图片"></p><h3>自定义配置</h3><p>buildConf.conf.ts 中主要负责配置一些构建过程中所需的插件、常量和选项,确保构建过程中能够根据配置进行适当的处理和优化。</p><p><img src="/img/remote/1460000044504559" alt="图片" title="图片"></p><h3>合并webpack配置</h3><p>合并webpack配置,确保小程序项目在构建时能够按照用户的配置和需求生成合适的 Webpack 配置。</p><p><img src="/img/remote/1460000044504560" alt="图片" title="图片"></p><h2>分析配置</h2><p>通过分析主包来进一步了解tarojs/mini-runner在其中的作用。</p><p><img src="/img/remote/1460000044504561" alt="图片" title="图片"></p><p>查看config配置</p><p><img src="/img/remote/1460000044504562" alt="图片" title="图片"></p><ul><li>projectName: 项目名称</li><li>date: 项目创建日期</li><li>designWidth: 设计稿宽度,用于进行适配</li><li>deviceRatio: 不同设备宽度的适配比例</li><li>sourceRoot: 源代码目录</li><li>outputRoot: 输出目录,根据环境变量进行不同的配置</li><li>plugins: 配置Taro框架使用的插件</li><li>env: 环境变量配置</li><li>defineConstants: 常量配置</li><li>copy: 复制文件的配置,将一些静态文件复制到输出目录</li><li>framework: 项目框架</li><li>mini: 针对小程序的配置</li><li>h5: 针对H5平台的配置</li><li>alias: 路径别名配置,简化在代码中的引用路径</li></ul><h3>defineConstants</h3><p>defineConstants 主要进行常量的相关配置。</p><p><img src="/img/remote/1460000044504563" alt="图片" title="图片"></p><p>首先将环境变量,静态常量,运行时常量合并为一个常量对象definePlugin,通过getDefinePlugin 函数返回了一个配置好的 DefinePlugin 实例,definePlugin是Webpack的一个内置插件,用于替换代码中的变量为其对应的值。</p><p>通过这个配置,Webpack 在构建时会将代码中的 APPNAME 替换为 process.env.APPNAME 的值。同样,其他常量也会按照相应的方式被替换。</p><p><img src="/img/remote/1460000044504564" alt="图片" title="图片"></p><h3>copy</h3><p>copy复制文件的配置,将一些静态文件复制到输出目录,</p><ul><li>patterns: 一个数组,指定了需要拷贝的文件或目录的规则</li><li>options: 配置选项</li></ul><p><img src="/img/remote/1460000044504565" alt="图片" title="图片"></p><p>不同环境拷贝不同的目标路径,主要用于对地图的适配。</p><p><img src="/img/remote/1460000044504566" alt="图片" title="图片"></p><p>在 patterns 数组中添加了一个额外的拷贝规则,将 plugin/doc 目录拷贝到输出目录的 doc 目录下,如果配置中存在copy 调取getCopyWebPackPlugin。</p><p><img src="/img/remote/1460000044504567" alt="图片" title="图片"></p><p>getCopyWebpackPlugin 函数接收一个包含 copy 配置的对象以及应用程序路径 appPath。通过 CopyWebpackPlugin 创建实例,配置 patterns,将配置项中的相对路径转换为绝对路径,确保正确的拷贝。返回创建的 CopyWebpackPlugin 实例。</p><p><img src="/img/remote/1460000044504568" alt="图片" title="图片"></p><h3>alias</h3><p>alias主要用于简化在代码中的引用路径。</p><p><img src="/img/remote/1460000044504569" alt="图片" title="图片"></p><p>resolve 是一个配置选项,用于指定解析模块请求的规则。alias 配置会被合并到 resolve 中。</p><p>在构建过程中,Webpack 将会使用这些别名来解析模块的引入路径。例如,当代码中出现 import '@/ecoExpress/someModule' 时,Webpack 将会解析为 path.resolve(__dirname, '..', 'src/ecoExpress/someModule')。</p><p><img src="/img/remote/1460000044504570" alt="图片" title="图片"></p><h3>mini</h3><h4>postcss</h4><p>pxtransform 配置:</p><ul><li>enable: true 表示开启对 CSS 中的 px 单位进行转换</li><li>config: 可以用于配置 pxtransform 的详细选项,比如配置项的转换规则<br>url 配置:</li><li>enable: true 表示开启对样式文件中的图片、字体等资源的 URL 转换</li><li>config: { limit: 1024 } 限制转换的资源大小,超过这个大小的资源将被单独输出文件,小于这个大小的资源将被转换成 base64 编码并嵌入样式文件中</li></ul><p>这个配置的目的是将小图片、字体等资源直接转换成 base64 编码,减少网络请求,提高小程序的加载速度。<br>cssModules 配置:</p><ul><li>enable: false 表示不开启 CSS Modules 功能。</li><li>config: 可以配置一些关于 CSS Modules 的选项,包括命名规则等</li></ul><p>开启了 CSS Modules,可以通过配置项进行定制化,CSS Modules 允许将 CSS 样式作用域限制在组件内,避免全局样式的污染。</p><p><img src="/img/remote/1460000044504571" alt="图片" title="图片"></p><p>查看config基础配置,进行了一些预处理。</p><p><img src="/img/remote/1460000044504572" alt="图片" title="图片"></p><h4>webpackChain</h4><p>在 webpack 中,webpackChain 通常指的是一种链式调用的方式来配置 Webpack 的构建过程。在 Taro 中,webpackChain 主要是用于自定义和扩展 Webpack 配置的工具。</p><p>Taro 封装了 webpack 的配置,提供了一些默认的配置,同时也允许开发者通过 webpackChain 自定义和扩展这些配置。这种链式调用的方式可以更方便地对 Webpack 配置进行修改和增强。</p><p>如下代码可知,mini-runner 将内部 webpackChain 和开发者配置的 webpackChain 相结合,得到最终的webpackChain。</p><p><img src="/img/remote/1460000044504573" alt="图片" title="图片"></p><p>针对小程序进行配置。</p><p><img src="/img/remote/1460000044504574" alt="图片" title="图片"></p><p>先查看mini中的webpackChain,查看第一个插件 hitchFeCompontsImportPlugin,可以得出主要功能是匹配不同依赖路径确定不同的配置。</p><p><img src="/img/remote/1460000044504575" alt="图片" title="图片"></p><p>multiPlatformPlugin,接着看下一个函数,这个方法的作用是为 Taro 项目配置 Webpack 的解析插件,限制只解析以 '@hb/' 开头的模块路径,以支持 Taro 项目的多平台打包需求。</p><p><img src="/img/remote/1460000044504576" alt="图片" title="图片"></p><p>使用 webpack-bundle-analyzer 插件,并传递一个空数组 [] 作为配置(webpack-chain中 use的第二个参数,作为插件的配置,可以不填,但必须要是数组)。</p><p><img src="/img/remote/1460000044504577" alt="图片" title="图片"></p><p>使用自定义的 Stats 类创建的插件。这个插件的作用是在 Webpack 构建完成后将统计信息写入一个文件(stats.json),以供进一步分析和处理。</p><p><img src="/img/remote/1460000044504578" alt="图片" title="图片"></p><ul><li>使用 chain.optimization 配置 Webpack 的优化</li><li>调用 .minimizer() 方法,传递一个包含 TerserPlugin 实例的数组。TerserPlugin 用于压缩 JavaScript 代码</li><li>extractComments: false,配置了禁止提取注释的选项,以防止将注释提取到单独的文件中</li></ul><p><img src="/img/remote/1460000044504579" alt="图片" title="图片"></p><h4>optimizeMainPackage</h4><p>配置项 optimizeMainPackage 控制了主包的优化,只有在构建目标环境是微信小程序(TARO_ENV === 'weapp')时才启用。主要的目的是在微信小程序中优化主包的构建。</p><p><img src="/img/remote/1460000044504580" alt="图片" title="图片"></p><p>判断 optimizeMainPackage.enable 是否为 true,如果为 true,则使用 getMiniSplitChunksPlugin 函数创建了一个插件实例,并将其配置合并了 optimizeMainPackage 和 fileType。这个插件的作用是对小程序主包进行分包优化。</p><p><img src="/img/remote/1460000044504581" alt="图片" title="图片"></p><p>Webpack 提供了 SplitChunkPlugin 进行分包优化。SplitChunksPlugin 插件可以将应用程序中共享的代码拆分成单独的块,以便将其从应用程序代码中分离出来,从而提高性能和加载速度。</p><h4>miniCssExtractPluginOption</h4><p>miniCssExtractPluginOption用于指定 Mini CSS Extract 插件的选项。,其中 ignoreOrder 主要是为了避免关于样式引入顺序的警告。当为 true 时,表示忽略 CSS 文件的引入顺序,不会抛出关于引入顺序的警告。</p><p><img src="/img/remote/1460000044504582" alt="图片" title="图片"></p><h4>baseLevel</h4><p>对于不支持模板递归的小程序(微信、QQ、京东小程序),在 DOM 层级达到一定数量后,Taro 会使用原生自定义组件协助递归。</p><p>简单理解就是 DOM 结构超过 N 层后,会使用原生自定义组件进行渲染。N 默认是 16 层,可以通过修改配置项 修改 N。</p><h3>H5</h3><p>再查看下H5中webpackChain的作用</p><p><img src="/img/remote/1460000044504583" alt="图片" title="图片"></p><p>首先跟小程序配置类似,先调用multiPlatformPlugin这个方法的作用是为 Taro 项目配置 Webpack 的解析插件,限制只解析以 '@hb/' 开头的模块路径,以支持 Taro 项目的多平台打包需求。</p><p>然后chain.module.rules.get('script').exclude.clear().add([...]);:这一段代码涉及到对 Webpack 规则的修改</p><ul><li>chain.module.rules.get('script') 表示获取名为 'script' 的规则</li><li>exclude.clear() 清空原有的排除规则</li><li>add([...]) 添加新的排除规则,其中传入的函数用于判断是否应该排除某个文件</li></ul><p>这个规则的目的是排除一些特定的文件,包括 @tarojs/components 目录下的文件,以满足一定条件的 node_modules 下的文件,但不包括包含 taro 和 @hb/ 的文件。为了定制 Taro 在 H5 平台的构建规则。</p><p><img src="/img/remote/1460000044504584" alt="图片" title="图片"></p><p>然后调用hitchFeCompontsImportPlugin。</p><p><img src="/img/remote/1460000044504585" alt="图片" title="图片"></p><h2>总结</h2><p>总的来说在编译方面,mini-runner 支持多端开发,使开发者能够通过一套代码适配不同的小程序平台。还提供了默认的构建配置,包括 loader、plugin、resolve 规则等,以满足 Taro 框架的开发需求,并允许开发者通过配置文件或插件进行对构建过程的定制和扩展。</p><p>在对webpack配置方面,mini-runner 提供了webpackChain方法,使得开发者可以在 Webpack 配置中进行链式调用,方便自定义和增强配置。封装了一套默认的 Webpack 配置,以适应 Taro 框架的特性和小程序平台的要求。此外,mini-runner 控制构建过程,提供一些配置项,例如是否监听文件变化、是否启用 source map 等。</p><h2>附录 - (mini-runner 部分源码解析)</h2><h3>目录结构</h3><p><img src="/img/remote/1460000044504556" alt="图片" title="图片"></p><h3>主流程(index)</h3><p>主要分为两个流程,根据项目配置生产webpack的构建配置,利用webpack进行代码编译。</p><p><img src="/img/remote/1460000044504557" alt="图片" title="图片"></p><h3>基础配置</h3><p>进入 buildConf 函数,由代码可知,首先调用了 getBaseConf ,进入该函数查看配置。</p><p><img src="/img/remote/1460000044504586" alt="图片" title="图片"></p><p>查看该配置,主要是Taro 构建过程中一部分 webpack 配置的初始化工作(包括文件扩展名、模块解析路径、别名等)。通过配置解析选项和引入 MultiPlatformPlugin 插件,支持跨平台文件。</p><ul><li>源文件使用的扩展名,这里包括 '.js', '.jsx', '.ts', '.tsx', '.mjs', '.vue'</li><li>指定导入模块时使用 package.json 中的哪个字段,这里的配置将优先使用 browser 属性解析文件,其次是 module,最后是 main</li><li>symlink</li><li>告诉 webpack 解析模块时应该搜索的目录,这里对应的就是 node_modules 目录</li><li>解析 webpack loader 包,指定 node_modules 目录</li><li>代码包是包含副作用的,不希望被 tree shaking 优化</li><li>配置node环境,在构建过程中对 fs(文件系统模块)和 path(路径模块)的引用替换为一个空对象,从而在浏览器环境中模拟文件系统和路径操作,在浏览器环境中,一些 Node.js 特定的模块是不可用的,因此需要通过这种方式进行处理</li><li>添加MultiPlatformPlugin 插件,支持跨平台文件</li></ul><p><img src="/img/remote/1460000044504558" alt="图片" title="图片"></p><h3>自定义配置</h3><p>查看 buildConf 的其余配置,这段代码主要负责初始化和配置一些构建过程中所需的插件、常量和选项,确保构建过程中能够根据配置进行适当的处理和优化</p><ul><li>如果是构建插件(isBuildPlugin 为真),就会处理复制文件的逻辑。如果存在 copy 对象,则将其现有的 patterns 属性提取出来,如果不存在,则创建一个空数组。随后,将插件相关的文件夹路径加入这个数组。最后,使用 Object.assign 将更新后的 patterns 放回到 copy 对象中。这样,如果之前已经有一些复制规则,现在就添加了插件相关的复制规则</li><li>配置插件,将 copy 属性解析为 copy-webpack-plugin 插件,加入到 webpack 中</li><li>设置环境变量</li><li>预备构建过程中所需的常量和入口文件的配置。首先,通过 getRuntimeConstants(runtime) 获取运行时常量,其中可能包含运行时所需的配置或环境常量。接着,通过 mergeOption([processEnvOption(env), defineConstants, runtimeConstants]) 合并来自不同来源的常量选项,包括从环境变量提取的配置、预定义的常量以及之前获取的运行时常量。最后,通过 getEntry({ sourceDir, entry, isBuildPlugin }) 获取项目的入口文件配置,其中包括源代码目录、入口文件的配置以及构建是否为插件</li><li>配置共享的代码块(Common Chunks)以用于构建。首先,根据是否构建插件来设置默认的共享代码块列表 defaultCommonChunks,其中包括了运行时、第三方库、Taro 框架和通用代码块。接着,通过一系列条件语句,允许用户自定义共享代码块的配置。如果 commonChunks 是一个函数,则调用它,将默认的共享代码块传递给它,允许用户根据需要修改或替换默认的配置。如果 commonChunks 是一个非空数组,则将其用作自定义的共享代码块配置。最后,通过调用 getDefinePlugin([constantsReplaceList]) 获取定义插件的配置,其中包括了前面整理好的常量替换列表。有利于灵活地配置和生成最终的共享代码块配置,以便在构建过程中进行优化</li><li>判断是否开启了主包优化,如果开启了就配置相应的分割插件</li></ul><p><img src="/img/remote/1460000044504587" alt="图片" title="图片"></p><p><img src="/img/remote/1460000044504588" alt="图片" title="图片"></p><p><img src="/img/remote/1460000044504589" alt="图片" title="图片"></p><p>Taro 构建过程中 webpack 配置的核心部分,通过链式调用 webpack-chain 库的方法逐步配置了 webpack 的各项参数,包括模式、入口、出口、目标、解析规则、插件、优化等</p><ul><li>mode:提供 mode 配置选项,告知 webpack 使用相应模式的内置优化。</li><li>devtool:控制是否生成 source-map。</li><li>entry:入口文件,也就是 app.js。</li><li>output:定义代码编译后的生产目录。</li><li>target:指定目标(target)环境。</li><li>resolve:合并 alias 别名选项。</li><li>module:配置 module,这里主要是配置一些不同的 loader。</li><li>plugin:配置 plugin 插件。</li><li>optimization:手动配置了一些编译选项优化。</li></ul><p><img src="/img/remote/1460000044504559" alt="图片" title="图片"></p><h3>webpack 代码编译</h3><p>webpack 的编译过程,支持 watch 模式,并提供了一些回调函数用于处理编译结果。</p><p><img src="/img/remote/1460000044504590" alt="图片" title="图片"></p><p>(本文作者:刘健)</p><p><img width="732" height="246" src="https://segmentfault.com/img/bVdajSo" alt="图片" title="图片"></p>
两轮车载图像感知与端侧视觉
https://segmentfault.com/a/1190000044449511
2023-12-06T16:46:21+08:00
2023-12-06T16:46:21+08:00
哈啰技术
https://segmentfault.com/u/hellotech
0
<h2>端侧视觉</h2><h3>端侧视觉与应用场景</h3><p>端侧视觉是指在终端设备上实现图像处理和计算机视觉的技术,它可以在设备本地进行计算和数据处理,支持实时图像处理和视觉分析。</p><p><img src="/img/remote/1460000044449513" alt="图片" title="图片"></p><p>AI领域是端侧视觉落地产品和方向非常多的领域,如手机端、手表、智能监控设备、平板电脑。目前的几个风口方向,如特斯拉FSD自动驾驶,也采用了纯视觉的端侧解决方案。VR和AR等视觉领域、机器人领域也采用了端侧视觉相关的技术。</p><h3>多种视觉方案的区别</h3><p><img src="/img/remote/1460000044449514" alt="图片" title="图片"></p><p>整体的视觉方案总结成5种,包括端侧视觉、云端视觉、混合视觉、边缘云视觉和传感器级视觉。对比的4个维度有数据处理位置、实时性要求、网络依赖性和用户的隐私与安全。</p><h3>端侧视觉领域相关技术</h3><p><img src="/img/remote/1460000044449515" alt="图片" title="图片"></p><p>我们在本地或用户端采集数据,通过训练模型的方式,把模型放到云端或端侧进行部署。接着服务端进行请求,通过前向推理的过程得到响应,反馈给服务端。</p><p>在端侧和云侧算法需要考虑很多因素,端侧会考虑系统架构、性能优化、并发处理、容错与故障处理、监控和日志、版本管理、安全性、合规性、资源管理等,云端则会考虑云服务选择、成本管理、弹性伸缩、数据传输和存储、服务级别协议(SLA)、云端安全等。</p><p><img src="/img/remote/1460000044449516" alt="图片" title="图片"></p><p>云端和端侧的主要区别在于推理过程,不得不提推理系统和推理引擎。推理系统保证算法推理的稳定性,推理引擎更多考虑具体的实施细节。</p><p><img src="/img/remote/1460000044449517" alt="图片" title="图片"></p><p>回归本源,从AI系统的全栈架构中看,可以分为五部分。一是体系架构,包括CPU、GPU、NPU等硬件设备、网络加速器和超级计算节点。二是编译编程,编译器在AI领域包括AI专用的编译器和传统的编译器。三是框架,包括常见的PyTorch、TensorFlow等AI推理框架和芯片厂商自研的推理引擎。四是开发层,包括Python等常见的编程语言。五是应用层,包括大模型、CV、NLP等。</p><h3>端侧视觉算法设计注意点</h3><ul><li>严格约束功耗、热量、模型尺寸小于设备内存</li><li>硬件算力对推理服务来说不足</li><li>模型在边缘更容易受到攻击</li><li>DNN平台多样,无通用解决方案</li></ul><h3>端侧视觉算法如何优化提升效果和性能</h3><ul><li>应用层算法优化:考虑到移动端部署的苛刻资源约束条件下,提供针对移动端部署的 AI 模型</li><li>高效率模型设计:通过模型压缩的量化、剪枝、蒸馏、神经网络结构搜索(NAS)等技术,减少模型尺寸</li><li>移动端框架-推理引擎:TensorFlow Lite,MNN、TensorRT,ONNX Runtime等推理引擎</li><li>移动端芯片:高效低功耗芯片支持,如 Google Edge TPU,NVIDIA Jetson等系列</li></ul><h2>两轮车载图像感知</h2><h3>算法落地</h3><p><img src="/img/remote/1460000044449518" alt="图片" title="图片"></p><p>两轮车载图像感知目前的落地在泊车功能,我们会智能识别车道线,通过摄像头处理车道线的位置信息、类别信息,从而判别停车是否规范。</p><h3>相机标定</h3><p>相机标定的目的是为了确定相机内部和外部参数,将图像坐标系与世界坐标系之间建立联系。</p><p>相机标定广泛应用于计算机视觉、机器人视觉、三维重建、虚拟现实等领域,它为后续的图像处理和分析提供了基础数据,保证了数据的精度和准确性,从而提高了系统的可靠性和稳定性。</p><h4>坐标系</h4><p><img src="/img/remote/1460000044449519" alt="图片" title="图片"></p><ul><li>世界坐标系:代表物体在真实世界里的三维坐标,坐标系用\( Xw、Yw、Zw \)表示;</li><li>相机坐标系:代表以相机光学中心为原点的坐标系,光轴与z轴重合,坐标系用\( Xc、Yc、Zc \)表示;</li><li>图像坐标系:代表相机拍摄图像的坐标系,原点为相机光轴与成像平面的交点,是图像的中心点,坐标系用X、Y表示;</li><li>像素坐标系:由于图像的基本单位是像素,所以该坐标系是图像上点在图像存储矩阵中的像素位置,坐标原点在左上角,坐标系用u、v表示。前三个坐标系的单位是毫米,而最后一个坐标系的单位是像素。</li></ul><p>(1)世界坐标系到相机坐标系的变换:世界坐标系是真实世界的基准坐标系,我们需要知道相机坐标系下的点在真实世界中的位置,利用其次坐标变换矩阵。</p><p><img src="/img/remote/1460000044449520" alt="图片" title="图片"></p><p>(2)相机坐标系到图像坐标系的变换:该变换可以看做是简单的射影变换(将相机看作小孔成像模型),将三维坐标变换为二维坐标。其中f为相机的焦距。</p><p><img src="/img/remote/1460000044449521" alt="图片" title="图片"></p><p>(3)图像坐标系到像素坐标系的变换:设图像x方向每毫米有\( fx\)个像素,y方向每毫米有\( fy\)个像素。其中\( cx、cy\)是图像坐标系原点在像素坐标系下的坐标。</p><p><img src="/img/remote/1460000044449522" alt="图片" title="图片"></p><p>内参矩阵取决于相机内部参数,外参矩阵取决于相机坐标系和世界坐标系的位置。</p><p><img src="/img/remote/1460000044449524" alt="图片" title="图片"></p><h4>相机畸变</h4><p><img src="/img/remote/1460000044449525" alt="图片" title="图片"></p><p>除了坐标系的因素,还需要考虑相机畸变的因素。径向畸变(枕型畸变、桶型畸变)是由于透镜本身质量决定的,切向畸变是由于透镜和像平面不平行导致,属于工程安装误差。</p><h4>张正友标定法</h4><p>目前解决这一问题成熟的一套方法是张正友标定法,感兴趣的同学可以看一下相关的论文。<br>Reference: Zhang Z . A Flexible New Technique for Camera Calibration[J]. IEEE Transactions on Pattern Analysis and Machine Intelligence, 2000, 22(11):1330-1334.</p><p><img src="/img/remote/1460000044449526" alt="图片" title="图片"></p><p><img src="/img/remote/1460000044449527" alt="图片" title="图片"></p><p><img src="/img/remote/1460000044449528" alt="图片" title="图片"></p><p><img src="/img/remote/1460000044449529" alt="图片" title="图片"></p><p><img src="/img/remote/1460000044449530" alt="图片" title="图片"></p><h3>泊车方案的做法与难点</h3><p>如图是算法引擎的架构。用户端创建订单后,通过硬件平台拿到码流信息交给算法引擎。算法引擎完成码流信息的编解码工作,解析完后送到算法,算法处理后把结果反馈给硬件平台,硬件平台再反馈给用户端。</p><p><img src="/img/remote/1460000044449531" alt="图片" title="图片"></p><h3>方案的落地</h3><p>我们做了CPU和NPU两套方案,CPU方案的难点在于传统算法的瓶颈较高,硬件对算法的内存以及耗时要求也比较严苛。NPU方案的难点在于算法组件的内存和实现技术突破。</p><h3>后续算法优化的方向</h3><ul><li>数据工程:多场景及多城市下的数据积累与数据仓库管理</li><li>开发流程:开发流程规范化,支持算法快速迭代部署</li></ul>
哈啰一站式业产研协同平台的建设与实践
https://segmentfault.com/a/1190000044383606
2023-11-13T11:24:59+08:00
2023-11-13T11:24:59+08:00
哈啰技术
https://segmentfault.com/u/hellotech
0
<p>10 月 26 日,思码逸 DevData Talks 邀请到了哈啰出行研发效能团队负责人高明国。他多年来负责公司业产研协同平台建设、效能度量工具体系建设、测试效能工具建设、稳定性工具建设、质量流程规范管理等。他这次为我们带来的主题是《哈啰一站式业产研协同平台建设与实践》,分享如何通过一系列效能方案与最佳实践后,帮助公司解决降本增效的难题。</p><p><img src="/img/remote/1460000044383608" alt="图片" title="图片"></p><p>我们将按照以下逻辑带大家回顾本次分享的主要内容:<br>1.面临的困境<br>2.建设方案<br>3.落地实践<br>4.展望未来</p><h2>面临的困境</h2><p>首先,如下图所示,是哈啰降本增效背景下组织面临的困境。左侧一类为管理困境,右侧一类为质量和效率困境。</p><p><img src="/img/remote/1460000044383609" alt="图片" title="图片"></p><p>管理困境主要分成三点:</p><ul><li>企业管理团队困境</li><li>业务团队困境</li><li>项目管理困境</li></ul><p>质量和效率困境主要分成三点:</p><ul><li>产研协同的困境</li><li>交付团队的困境</li><li>效能度量的困境</li></ul><h2>建设方案</h2><p>我们制定了高效交付体系化协同解决方案,主要经历了三个步骤,即标准化、线上化和数字化。</p><p>第一步,将流程标准化后,再通过我们线上系统的能力去承载这些流程,让这些流程商当中的关键节点变成我们平台上的一个能力,或者平台上的某一个关键节点,让整个研发过程的数据能够标准化且沉淀下来,最后则基于这个数据去做度量,做词语改进。</p><p><img src="/img/remote/1460000044383610" alt="图片" title="图片"></p><p>接着,我们能看到如下图的战略一致性,其实战略一致性主要就是为了让组织目标能够更加聚焦,资源投入能够更加地清晰。在业务层面和交互层面,实际上我们去落地组织层面规划战略的一些目标,即我们通过建设一个战略,能够让管理人从纵横业务和横向部门的交叉去分析,然后管理我们资源投入,去监控它到底有没有异常。因此,我们在这里做了四件事情:</p><ul><li>业务分组</li><li>战略规划</li><li>目标关联</li><li>兵力监控</li></ul><p><img src="/img/remote/1460000044383611" alt="图片" title="图片"></p><p>哈啰为什么会选择项目协同呢?这是因为项目里面很重要的两点特性,一个就是聚焦,一个就是价值。我们通过项目协同可以很清楚地知道这个项目实现了什么价值,能给业务带来什么价值,也能让我们做事更加聚焦。</p><p>在哈啰,我们将项目分成两大类型:</p><ul><li>战役项目</li><li>非战役项目</li></ul><p>两个战役项目包含立项专项的和需求集项目,以及日常支持。立项专项主要是一些比较大且比较重要的项目。需求集项目则是像一些迭代,例如系统已经建完了,但可能在后面进入到一些 bug 的修复或者小需求,我们就会将它放入需求集项目里面。而日常支持,比如针对用户使用过程中遇到的一些问题,我们也许会做一些QA或者技术支持,就会放入日常支持里面。</p><p><img src="/img/remote/1460000044383612" alt="图片" title="图片"></p><p>业产协同是通过一套产品化的方案,让整个业务和这个产品能够通过平台去进行衔接,让业务在平台上去提交你的MRD,然后产品去承接这个对应的MRD,再通过MIE和我们的这个变更去关联,将整个链路串起来。</p><p><img src="/img/remote/1460000044383613" alt="图片" title="图片"></p><p><img src="/img/remote/1460000044383614" alt="图片" title="图片"></p><h2>落地实践</h2><p>在精细化运营中,能看到我们从战略规划层面做了以下几点:</p><ul><li>组织效能</li><li>项目协同</li><li>持续交付</li><li>工程能力</li><li>生产质量</li></ul><p><img src="/img/remote/1460000044383615" alt="图片" title="图片"></p><p>组织效能方面,我们能通过数据评估资源投入和目标规划是否匹配,当前项目是否存在风险,关注支撑目标项目资源健康度,亦或是分析各项目是否进入价值回收阶段,以使我们能及时做出正确决策。</p><p><img src="/img/remote/1460000044383616" alt="图片" title="图片"></p><p>另外,由于我们整个项目过程全都线上化,所以我们会在项目过程中定义哪些是属于项目异常,然后通过巡检把它自动推到我们的个人工作台上面,以及在钉钉群里面也会生成告警,并且会通过经营驾驶舱管理我们整个项目的一个健康度。</p><p><img src="/img/remote/1460000044383617" alt="图片" title="图片"></p><p>此外,我们也会进行质量管控,我们会关注一些当前阶段核心的指标,然后通过质量数据的稳定提升,能够让当前流程和其团队能够更加适配,研测和协同能够更加紧密。</p><p><img src="/img/remote/1460000044383618" alt="图片" title="图片"></p><p>效率分析指的是在资源变化较大的情况下,使用个体度量更合适,比如:</p><ul><li>研发测试交付时长比:体现测试成熟度、环境稳定性、测试工具的完善。</li><li>人均交付需求数:参与度越高说明测试团队需求吞吐能力越大。</li><li>需求参与度:短期内测试参与度越高,说明覆盖的需求更多,生产漏测风险更小。</li></ul><p><img src="/img/remote/1460000044383619" alt="图片" title="图片"></p><p>针对紧急发布以及发布失败,我们也在做持续的治理。如下图所示能看到我们在21年的发布成功率在94%左右,直到23年已近97%左右。同时,也能观察到生产紧急发布和回滚发布数据在急剧下降,这说明我们紧急发布和回滚发布量逐渐变少。这是因为我们通过以下几点流程提升了发布效率:</p><ul><li>落地发布计划</li><li>优化平台能力</li><li>拉通上下游依赖</li><li>收口部署权限</li></ul><p><img src="/img/remote/1460000044383620" alt="图片" title="图片"></p><p>在我们面临的困境中,由于我们缺少相关的内容规范,因此整个APP发版经常会有延期的情况,所以我们制定了如下图APP发版的流程规范,通过定标准、设卡点,然后跟进过程且抓结果,最后做分析改进。</p><p><img src="/img/remote/1460000044383621" alt="图片" title="图片"></p><p>最终在生产质量上,我们会通过后事前监控、事中应急、事后复盘去做持续改进,例如:</p><ul><li>基于工程变更的问题上报机制:对所有的生产,所有的变更进行统一管控。</li><li>生产问题的快速定位:能够在发现问题的时候进行5分钟的生产问题聚合,以便能做快速的回滚。</li><li>故障SLA检测:针对生产的故障做四个复盘,复盘完了以后会去做SLA故障检测,包括SLA目标达成的一个情况。</li><li>故障复盘持续改进:将所有的故障录入系统后,持续跟进系统生成的所有改进项,确保落地所有改进项。</li></ul><p><img src="/img/remote/1460000044383622" alt="图片" title="图片"></p><h2>总结</h2><p>简要总结几点做度量的建议:</p><ul><li>数据可信:如果你所生产的研发过程中产生的数据都是模糊的,或者都是不准确的,那将是没有办法去做度量的。</li><li>人才可用:比如公司说要通过AI或者大数据让我们去提效,而如果公司现在根本就不具备这样的人才做这件事情,那这样的方案必然是不可行的。</li><li>措施可行:制定的提效方案一定要是可落地的,不要制定一些假大空的东西。</li></ul><h2>展望未来</h2><p>我们其实在降本增效做了差不多两年左右,虽然这平台能力是我们建造及推进,但在一些分析上面还不足够,所以我们会在往后从点到线,去拓展更多更大的一个范围,也会更多地和业务去做协同。此外,我们会做更多的数据挖掘,尽可能地发现我们在研发过程中各个环节遇到的一些不管是流程问题还是效率问题,然后去做持续的改进,帮助整个团队不断地提效。</p>
apollo线上问题的分析
https://segmentfault.com/a/1190000044366347
2023-11-06T14:39:33+08:00
2023-11-06T14:39:33+08:00
哈啰技术
https://segmentfault.com/u/hellotech
0
<p>最近发生了一个apollo带宽被打满的问题,因此看了一下apollo的部分设计和源码,本文针对发生的apollo带宽问题,聊聊apollo部分设计的理解。</p><h2>问题现象</h2><p>如下图所示:问题当天的15:20—16:20接近一个小时的时间,一直有db网络带宽的抖动,到了16:20网络带宽彻底打满,导致触发阿里云限流,导致apollo服务端整体不可用。</p><p>apollo配置是会缓存在客户端应用本地,因此服务端db的带宽增长肯定不是查询的apollo查询导致的,而是变更导致的,是什么配置变更会导致服务端db带宽增长如此巨大呢?</p><p><img src="/img/remote/1460000044366349" alt="图片" title="图片"></p><h2>apollo设计</h2><h3>客户端设计图</h3><p>下面是一张官方的apollo客户端设计图,从设计图里面可以看出用户新增或者修改配置的流程如下:</p><ul><li>用户新增或者修改配置,将配置内容在apollo服务端进行更新</li><li>apollo客户端有两种方式进行配置的更新(推拉结合):主动进行配置更新的推送、定时拉取配置更新(兜底)</li><li>apollo客户端会将服务端的配置缓存在内存中</li><li>apollo客户端会将配置更新通知给应用程序</li><li>apollo客户端会将配置缓存到本地文件中(以便后续异常后从本地文件恢复)</li></ul><p><img src="/img/remote/1460000044366350" alt="图片" title="图片"></p><h3>流程问题分析</h3><p>从上面设计图可以看到,客户端更新配置到内存是服务器内部的内存写入,客户端从内存写入本地缓存是客户端服务内部IO,因此客户端配置更新不会导致服务端的db带宽抖动。因此问题出现在服务端的配置更新。下面我们就来看apollo服务端更新配置的逻辑。</p><p><strong>1.apollo推拉结合推送</strong><br>apollo将数据同步到客户端是通过推拉结合的方式,核心是两个类(RemoteConfigRepository、RemoteConfigLongPollService)。</p><p>推:即服务端将变更的配置主动推送给客户端(保障实时性)。而apollo的推送,则是通过长轮询实现的,核心的实现类为RemoteConfigLongPollService。</p><p>拉:即客户端定时访问服务端配置,检测配置是否更新,若更新,则拉取服务端最新配置(可理解为推送失败的兜底逻辑)。定时拉取则是通过job定时(五分钟)去查询配置是否变更。核心实现类为RemoteConfigRepository。</p><p><strong>2.长轮询</strong><br>长轮询流程可以看下图所示,即apollo客户端在启动后,会发起一个http的长轮询,而apollo服务端会将该长轮询挂起,直到该长轮询对应的配置出现了变更,则会通知给客户端,让客户端进行最新的配置拉取。</p><p><img src="/img/remote/1460000044366351" alt="图片" title="图片"></p><p>具体源码如下所示:<br>RemoteConfigLongPollService类加载完后会执行startLongPolling。</p><p>以下是去除部分代码的的startLongPolling方法源码,可以看到startLongPolling调用了doLongPollingRefresh进行长轮询,而该方法执行了什么呢?</p><pre><code>private void startLongPolling() {
try {
m_longPollingService.submit(new Runnable() {
@Override
public void run() {
//调用长轮询方法
doLongPollingRefresh(appId, cluster, dataCenter);
}
});
} catch (Throwable ex) {
m_longPollStarted.set(false);
ApolloConfigException exception =
new ApolloConfigException("Schedule long polling refresh failed", ex);
Tracer.logError(exception);
logger.warn(ExceptionUtil.getDetailMessage(exception));
}
}</code></pre><p>以下是去除了部分代码的doLongPollingRefresh源码,可以看到:</p><ul><li>首先doLongPollingRefresh进行了一次http的长轮询</li><li>如果服务端长轮询返回200,并且有数据,则代表服务端代码进行了更新,则调用notify方法进行客户端的配置更新</li><li>如果服务端长轮询返回304,或者无数据,则代表没有更新</li><li>若代码存在异常,则最外层的while循环会不断的进行重试,而重试的逻辑在com.ctrip.framework.apollo.core.schedule.ExponentialSchedulePolicy的fail方法中,可以看到按照2的倍数进行重试,即2秒,4秒,8秒,16秒以此类推,直到达到最大的重试时间120秒,后续重试间隔不再变大,按照120秒间隔进行不断重试</li></ul><pre><code>private void doLongPollingRefresh(String appId, String cluster, String dataCenter) {
while (!m_longPollingStopped.get() && !Thread.currentThread().isInterrupted()) {
Transaction transaction = Tracer.newTransaction("Apollo.ConfigService", "pollNotification");
String url = null;
try {
//执行长轮询
url =
assembleLongPollRefreshUrl(lastServiceDto.getHomepageUrl(), appId, cluster, dataCenter,
m_notifications);
HttpRequest request = new HttpRequest(url);
request.setReadTimeout(LONG_POLLING_READ_TIMEOUT);
transaction.addData("Url", url);
final HttpResponse<List<ApolloConfigNotification>> response =
m_httpUtil.doGet(request, m_responseType);
logger.debug("Long polling response: {}, url: {}", response.getStatusCode(), url);
if (response.getStatusCode() == 200 && response.getBody() != null) {
updateNotifications(response.getBody());
updateRemoteNotifications(response.getBody());
transaction.addData("Result", response.getBody().toString());
notify(lastServiceDto, response.getBody());
}
//try to load balance
if (response.getStatusCode() == 304 && random.nextBoolean()) {
lastServiceDto = null;
}
m_longPollFailSchedulePolicyInSecond.success();
transaction.addData("StatusCode", response.getStatusCode());
transaction.setStatus(Transaction.SUCCESS);
} catch (Throwable ex) {
long sleepTimeInSecond = m_longPollFailSchedulePolicyInSecond.fail();
try {
TimeUnit.SECONDS.sleep(sleepTimeInSecond);
} catch (InterruptedException ie) {
//ignore
}
} finally {
transaction.complete();
}
}
}</code></pre><pre><code>public long fail() {
long delayTime = this.lastDelayTime;
if (delayTime == 0L) {
delayTime = this.delayTimeLowerBound;
} else {
//delayTimeUpperBound为120
delayTime = Math.min(this.lastDelayTime << 1, this.delayTimeUpperBound);
}
this.lastDelayTime = delayTime;
return delayTime;
}</code></pre><h3>长轮询整体流程</h3><p>长轮询的整体流程如下所示:</p><ul><li>apollo客户端启动后会通过RemoteConfigLongPollService类发起一个长轮询(超时90秒),调用apollo服务端的notifications/v2接口,apollo服务端会将长轮询挂起</li><li>如果有配置变更,apollo服务端会通知客户端存在配置变更</li><li>apollo客户端的RemoteConfigLongPollService类接收到变更通知,会调用RemoteConfigRepository进行配置变更的同步</li></ul><p><img src="/img/remote/1460000044366352" alt="图片" title="图片"></p><p><strong>1.定时拉取</strong><br>具体源码如下所示:<br>RemoteConfigRepository加载完毕后会执行schedulePeriodicRefresh方法,该方法设置可定时任务的间隔为5分钟执行同步数据的trySync()方法。trySync方法会执行sync()逻辑,然后sync()方法会执行loadApolloConfig()方法加载apollo服务端的最新配置。</p><pre><code>private void schedulePeriodicRefresh() {
//定时拉取,间隔时间为5分钟
m_executorService.scheduleAtFixedRate(
new Runnable() {
@Override
public void run() {
trySync();
}
}, m_configUtil.getRefreshInterval(), m_configUtil.getRefreshInterval(),
m_configUtil.getRefreshIntervalTimeUnit());
}</code></pre><p>方法的省略代码如下所示:</p><ul><li>首先loadApolloConfig进行了一次的请求</li><li>如果服务端长轮询返回304,或者无数据,则代表没有更新,则直接返回</li><li>如果服务端不是返回304,则代表有更新,则返回更新的appID,namespace、cluster等信息</li><li>若代码存在异常,则最外层的for循环会进行间隔1秒的重试,重试的逻辑为重试2次,如果再重试失败则打印异常日志</li></ul><pre><code>private ApolloConfig loadApolloConfig() {
int maxRetries = m_configNeedForceRefresh.get() ? 2 : 1;
long onErrorSleepTime = 0; // 0 means no sleep
Throwable exception = null;
List<ServiceDTO> configServices = getConfigServices();
String url = null;
//异常的重试,最多2次,
for (int i = 0; i < maxRetries; i++) {
List<ServiceDTO> randomConfigServices = Lists.newLinkedList(configServices);
Collections.shuffle(randomConfigServices);
if (m_longPollServiceDto.get() != null) {
randomConfigServices.add(0, m_longPollServiceDto.getAndSet(null));
}
for (ServiceDTO configService : randomConfigServices) {
if (onErrorSleepTime > 0) {
try {
m_configUtil.getOnErrorRetryIntervalTimeUnit().sleep(onErrorSleepTime);
} catch (InterruptedException e) {
//ignore
}
url = assembleQueryConfigUrl(configService.getHomepageUrl(), appId, cluster, m_namespace,
dataCenter, m_remoteMessages.get(), m_configCache.get());
HttpRequest request = new HttpRequest(url);
try {
//请求apollo服务端是否存在变更
HttpResponse<ApolloConfig> response = m_httpUtil.doGet(request, ApolloConfig.class);
m_configNeedForceRefresh.set(false);
m_loadConfigFailSchedulePolicy.success();
transaction.addData("StatusCode", response.getStatusCode());
transaction.setStatus(Transaction.SUCCESS);
if (response.getStatusCode() == 304) {
logger.debug("Config server responds with 304 HTTP status code.");
return m_configCache.get();
}
ApolloConfig result = response.getBody();
//返回变更配置的namespace、cluster、appID等信息
return result;
} catch (ApolloConfigStatusCodeException ex) {
ApolloConfigStatusCodeException statusCodeException = ex;
transaction.setStatus(statusCodeException);
} catch (Throwable ex) {
transaction.setStatus(ex);
} finally {
transaction.complete();
}
//异常重试间隔,1秒钟
onErrorSleepTime = m_configNeedForceRefresh.get() ? m_configUtil.getOnErrorRetryInterval() :
m_loadConfigFailSchedulePolicy.fail();
}
}
}</code></pre><p><strong>2.定时任务流程</strong></p><p><img src="/img/remote/1460000044366353" alt="图片" title="图片"></p><p>定时任务流程较为简单,apollo客户端的定时任务每隔五分钟会进行一次调用,拉取最新变化的配置进行更新。</p><p><strong>3.总结</strong></p><ul><li>配置更新有两种方式,定时任务每隔五分钟的拉取和90秒钟的长轮询</li><li>每隔五分钟的拉取会按照namespace维度,拉取变化的kv对应的整个namespace配置,其失败重试机制为失败重试两次</li><li>90秒钟的长轮询会有两个交互<br>a. 先通过notifications/v2的接口,判断是否存在配置变更,该长轮询接口只返回存在变更的namespace,不返回具体的配置信息<br>b. 如果存在配置变更,则进行namespace维度的配置同步<br>c. 长轮询的失败会按照即2秒,4秒,8秒,16秒.......120秒,120秒进行重试</li></ul><h2>问题点</h2><p>根据上述总结,可以基本得出问题点:</p><ul><li>首先,配置的更新会按照namespace维度去apollo服务端拉取,而每次apollo服务端会从db拉取namespace的数据,若单个namespace有1000个key,每个key有1K,则一个namespace的大小为1M左右。若Apollo客户端有200台机器,则每次配置更新会有200MB的db带宽访问</li><li>5分钟的定时拉取虽然只有两次重试,但是每隔五分钟就会按照namespace维度请求全量配置</li><li>90秒的长轮询失败会一直进行重试</li></ul><p>结合上诉问题点和数据库慢查询,以及apollo的变更情况,基本可以得出问题出现的原因:</p><ul><li>之前完成了大促的最后一次压测,大家都对应用进行了扩容</li><li>每台机器相当于一个apollo的客户端,由于扩容导致apollo客户端数量大大增加</li><li>当天15:20-16:20这段时间,部分机器较多的应用,同时更新了apollo配置,且部分apollo配置的namespace较大,则会出现db带宽的异常升高</li><li>长时间的db带宽抖动,加上更多的大namespace配置变更,则会引起db带宽限流,导致长轮询和定时拉取逻辑失败</li><li>长轮询失败会不断进行重试,定时任务也会不断进行同步,导致整个apollo服务端宕机</li></ul><h2>优化</h2><p>针对apollo的上述问题,是否存在优化点?以下是我的部分想法:</p><ul><li>如:长轮询和定时任务加上失败重试次数,如果一定时间内超过一定次数,则认为服务端宕机,不再请求?</li><li>如:配置的变更同步不按照namespace维度进行同步,按照key维度进行同步?</li><li>如:数据库新增md5字段,定时任务判断配置是否变化可以根据服务端缓存文件的md5和数据库的md5进行判断,不再直接拉取全量数据?</li></ul><p>(本文作者:柳健强)</p><p><img width="732" height="246" src="/img/bVdajSo" alt="image.png" title="image.png"></p>
Flink消费kafka数据同步问题排查
https://segmentfault.com/a/1190000044347411
2023-10-30T14:21:40+08:00
2023-10-30T14:21:40+08:00
哈啰技术
https://segmentfault.com/u/hellotech
0
<p>我们有一个flink任务,消费的kafka的数据,写入到es,非常简单的逻辑,但是出现了数据丢失的情况,之前没遇到过,初步猜想是转换逻辑或脏数据的影响,排查了一圈,未发现Exception等相关信息。猜想是写入频率太快,es写入的时候,出现了version conflict,也没找到相关证据。</p><p>从埋点日志来看,这个消息根本没进入到flink内部,因此也就排除了是转换逻辑出错或者是es写入失败导致。</p><p>按照猜想,业务能感知到数据同步出现了问题,大概率是丢失了大量的数据,而且根本没进入到flink内部,因此怀疑是单分区不消费的情况,之前因为线程hang死也发生过类似的情况。</p><p>去统计了一下消费分区,还真发现了问题,我们kafka有5个分区,统计出来的只有3个分区在消费。</p><p><img src="/img/remote/1460000044347413" alt="图片" title="图片"></p><p>同时我们用同一个程序,起了另外一个任务,发现了消费分区是0-4正常的5个,问题具有随机性,时好时坏?</p><p><img src="/img/remote/1460000044347414" alt="图片" title="图片"></p><p>发给更多人一起排查,被同事一眼看到bootstrap.server里不仅配置了pro集群,还混入了pre集群地址,bootstrap.servers = [business-s2-002-kafka1.xxxx.com.cn:9092, business-s2-002-kafka2.xxxx.com.cn:9092,business-s2-002-kafka3.xxxx.com.cn:9092,pre-kafka1.xxxx.com.cn:9092,pre-kafka2.xxxx.com.cn:9092,pre-kafka3.xxxx.com.cn:9092],不知道是不是这个问题导致的,因此要从源代码层面寻找一些证据和支持。</p><p>按照网上谷歌出来的理论讲解,kafka的消费者在连接kafka server的时候,先选bootstrap.server的第一个地址,如果第一个连不上,再连第二个,实际上看起来并非如此...</p><p>消费组是sp2_group_name_G_2023_10_08_16_58_03,topic是test_topic_A,在pro的kafka集群里有0-4共计5个分区,而在pre集群里有一个同样的topic,他有0-2共3个分区。</p><p>关键性的日志如下:</p><p><img src="/img/remote/1460000044347415" alt="图片" title="图片"></p><p>对应到FlinkKafkaConsumerBase.java的源代码,查找线索,主要看open和run两个函数,大概搞清楚了整个流程。</p><p>程序的第一步,根据时间戳到kafka的server读取到每个分区的offset,作为初始的offset传给kafka,调用的是如下方法。</p><p><img src="/img/remote/1460000044347416" alt="图片" title="图片"></p><p>在open方法里,因为我们的场景是指定了offset,并且第一次启动的时候,没有状态,因此打日志的地方是这段代码 : (因为这里的TIMESTAMP模式有bug,因此没有直接用这个)。</p><p><img src="/img/remote/1460000044347417" alt="图片" title="图片"></p><pre><code>LOG.info("Consumer subtask {} will start reading the following {} partitions from the specified startup offsets {}: {}", new Object[]{this.getRuntimeContext().getIndexOfThisSubtask(), this.subscribedPartitionsToStartOffsets.size(), this.specificStartupOffsets, this.subscribedPartitionsToStartOffsets.keySet()});</code></pre><p>这里面有几个关键变量subscribedPartitionsToStartOffsets和specificStartupOffsets,因此追寻这2个变量,specificStartupOffsets这个是我们上面的方法传入并且设置的指定offset逻辑,而subscribedPartitionsToStartOffsets则非常关键,他来源于this.partitionDiscoverer.discoverPartitions(),分区发现。</p><pre><code>List<KafkaTopicPartition> allPartitions = this.partitionDiscoverer.discoverPartitions();</code></pre><p>现在我们把整个逻辑串一下。</p><p>第一步,根据时间戳读取offset,我们从日志里看到,他读到了0-4个分区,并且也读到了他们的offset。</p><p>第二步,每一个消费线程(subtask 0-3),通过discoverPartitions去读取自己分配到的分区(服务端的协调节点负责分配,有RR等多种算法,网上讲解很多)。</p><p>我们发现,最后分配到的情况如下:<br>subtask0 => partition3,<br>subtask1 => partition0,<br>subtask2 => partition1,<br>subtask3 => partition2</p><p>4分区不见了?是为什么呢?由于去kafka server拿分配分区的时候,每个subtask发送1次请求,连接的server则是从bootstrap.server里随机选取的,因此可能连上pro的kafka集群(因此分配到了分区3),也可能连到了pre集群,因此可能被分配分区0-2(当然也不排除是pro的),这里就非常混乱,你搞不清楚这次subtask请求的到底是pro集群的还是pre集群的,因此领的分区,也不知道是来自哪里,不过可以肯定的是3分区是来自pro,至于4分区没有的原因,可能剩下的subtask都连的是pre集群?(猜想)</p><p>还有个疑问,这里连上了0-3分内,为什么日志里统计出来,只有0,2,3,其中的1分区一条数据也没有?</p><p><img src="/img/remote/1460000044347418" alt="图片" title="图片"></p><p>这就涉及第三步。</p><p>第三步,去拿数据。flink是在内部建了一个createFetcher,直接连接server去做数据拉取。这里也涉及到建立连接,如果这个时候你用的是pro的offset,连接的是pre集群去拉取数据,由于这个offset很大(在pre里根本不存在),是不是就拉不到数据了呢?毕竟pre集群的数据少offset很小,而pro的offset很大。</p><p><img src="/img/remote/1460000044347419" alt="图片" title="图片"></p><p>由于没有去看拿时间戳、discoverPartitions和createFetcher建立连接的代码,因此对于bootstrap.server的随机选择只是猜想,因此用另外一个任务去证明他。我们新建了第二个任务,相同的bootstrap.server的配置,日志如下:</p><p><img src="/img/remote/1460000044347421" alt="图片" title="图片"></p><p>从日志中,我们看到拿offset的地方,只拿到了0-2三个分区,也就是拿offset连接的是pre(具有随机性,并且确认bootstrap.server里的机器都是活的),因此第一步请求的server肯定是随机选择的。</p><p>而在discoverPartitions的时候第二步的结果是:<br>subtask0 => [partition1,partition4]<br>subtask1 => [partition2]<br>subtask2 => [partition3,partition0]<br>subtask3 => 日志里没出现???</p><p>所以这里的discoverPartitions又连接的是pro集群的kakfa。</p><p>整个代码、日志和现象都对上了,因此也就确认了就是server的配置问题,由于有些源码没有细看,因此可能会有一些错误,供参考。</p><p>把bootstrap.server配置正确之后,分配的分区再也没有混乱了。</p><p><img src="/img/remote/1460000044347422" alt="图片" title="图片"></p><p>(本文作者:任天兵)</p><p><img width="732" height="246" src="https://segmentfault.com/img/bVc86qd" alt="图片" title="图片"></p>
React 代码如何跑在小程序上?
https://segmentfault.com/a/1190000044311823
2023-10-17T16:49:44+08:00
2023-10-17T16:49:44+08:00
哈啰技术
https://segmentfault.com/u/hellotech
1
<p>标题中我们提出一个问题:react 代码如何跑在小程序上?目前看来大致两种思路:</p><ol><li>把 react 代码编译成小程序代码,这样我们可以开发用 react,然后跑起来还是小程序原生代码,结果很完美,但是把 react 代码编译成各个端的小程序代码是一个力气活,而且如果想用 vue 来开发的话,那么还需要做一遍 vue 代码的编译,这是 taro 1/2 的思路。</li><li>我们可以换个问题思考,react 代码是如何跑在浏览器里的?</li></ol><ul><li>站在浏览器的角度来思考:无论开发用的是什么框架,React 也好,Vue 也罢,最终代码经过运行之后都是调用了浏览器的那几个 BOM/DOM 的 API ,如:createElement、appendChild、removeChild 等。</li><li>Taro 3 主要通过在小程序端模拟实现 DOM、BOM API 来让前端框架直接运行在小程序环境中。</li></ul><p>下面我们具体看看各自的实现。</p><h2>Taro 1/2</h2><p>Taro 1/2 的架构主要分为:编译时 和 运行时。</p><p>其中编译时主要是将 Taro 代码通过 Babel 转换成 小程序的代码,如:JS、WXML、WXSS、JSON。</p><p>运行时主要是进行一些:生命周期、事件、data 等部分的处理和对接。</p><h3>Taro 编译时</h3><p>Taro 的编译,使用 babel-parser 将 Taro 代码解析成抽象语法树,然后通过 babel-types 对抽象语法树进行一系列修改、转换操作,最后再通过 babel-generate 生成对应的目标代码。</p><p>整个编译时最复杂的部分在于 JSX 编译。</p><p>我们都知道 JSX 是一个 JavaScript 的语法扩展,它的写法千变万化,十分灵活。这里我们是采用 穷举 的方式对 JSX 可能的写法进行了一一适配,这一部分工作量很大,实际上 Taro 有大量的 Commit 都是为了更完善的支持 JSX 的各种写法。</p><h3>Taro 运行时</h3><p>接下来,我们可以对比一下编译后的代码,可以发现,编译后的代码中,React 的核心 render 方法 没有了。同时代码里增加了 BaseComponent 和 createComponent ,它们是 Taro 运行时的核心。</p><pre><code>// 编译前
import Taro, { Component } from '@tarojs/taro'
import { View, Text } from '@tarojs/components'
import './index.scss'
export default class Index extends Component {
config = {
navigationBarTitleText: '首页'
}
componentDidMount () { }
render () {
return (
<View className=‘index' onClick={this.onClick}>
<Text>Hello world!</Text>
</View>
)
}
}
// 编译后
import {BaseComponent, createComponent} from '@tarojs/taro-weapp'
class Index extends BaseComponent {
// ...
_createDate(){
//process state and props
}
}
export default createComponent(Index)</code></pre><p>BaseComponent 主要是对 React 的一些核心方法:setState、forceUpdate 等进行了替换和重写,结合前面编译后 render 方法被替换,大家不难猜出:Taro 当前架构只是在开发时遵循了 React 的语法,在代码编译之后实际运行时,和 React 并没有关系。</p><p>而 createComponent 主要作用是调用 Component() 构建页面;对接事件、生命周期等;进行 Diff Data 并调用 setData 方法更新数据。</p><p>这样的实现过程有三⼤缺点:</p><ol><li>JSX ⽀持程度不完美。Taro 对 JSX 的⽀持是通过编译时的适配去实现的,但 JSX ⼜⾮常之灵活,因此还不能做到 100% ⽀持所有的 JSX 语法。 JSX 是一个 JavaScript 的语法扩展,它的写法千变万化,十分灵活。之前Taro团队是采用穷举的方式对 JSX 可能的写法进行了一一适配,这一部分工作量很大。</li><li>不⽀持 source-map。Taro 对源代码进⾏了⼀系列的转换操作之后,就不⽀持 source-map 了,⽤户 调试、使⽤这个项⽬就会不⽅便。</li><li>维护和迭代⼗分困难。Taro 编译时代码⾮常的复杂且离散,维护迭代都⾮常的困难。</li></ol><h2>Taro 3</h2><p>Taro 3 则可以大致理解为解释型架构(相对于 Taro 1/2 而言),主要通过在小程序端模拟实现 DOM、BOM API 来让前端框架直接运行在小程序环境中,从而达到小程序和 H5 统一的目的。</p><p>而对于生命周期、组件库、API、路由等差异,依然可以通过定义统一标准,各端负责各自实现的方式来进行抹平。</p><p>而正因为 Taro 3 的原理,在 Taro 3 中同时支持 React、Vue 等框架,甚至还支持了 jQuery,还能支持让开发者自定义地去拓展其他框架的支持,比如 Angular,Taro 3 整体架构如下:</p><p><img src="/img/remote/1460000044311825" alt="图片" title="图片"></p><h3>模拟实现 DOM、BOM API</h3><p>Taro 3 创建了 taro-runtime 的包,然后在这个包中实现了 一套 高效、精简版的 DOM/BOM API(下面的 UML 图只是反映了几个主要的类的结构和关系):</p><p><img src="/img/remote/1460000044311826" alt="图片" title="图片"></p><ul><li>TaroEventTarget类,实现addEventListener和removeEventListener。</li><li>TaroNode类继承TaroEventTarget类,主要实现insertBefore、appendChild等操作 Dom 节点的方法。下面在页面渲染我们会具体看这几个方法的实现。</li><li>TaroElement类继承TaroNode类,主要是节点属性相关的方法和dispatchEvent方法,dispatchEvent方法在下面讲事件触发的时候也会涉及到。</li><li>TaroRootElement类继承TaroElement类,其中最主要是enqueueUpdate和performUpdate,把虚拟 DOM setData 成小程序 data 的操作就是这两个函数。</li></ul><p>然后,我们通过 Webpack 的 ProvidePlugin 插件,注入到小程序的逻辑层。</p><p>Webpack ProvidePlugin 是一个 webpack 自带的插件,用于在每个模块中自动加载模块,而无需使用 import/require 调用。该插件可以将全局变量注入到每个模块中,避免在每个模块中重复引用相同的依赖。</p><pre><code>// trao-mini-runner/src/webpack/build.conf.ts
plugin.providerPlugin = getProviderPlugin({
window: ['@tarojs/runtime', 'window'],
document: ['@tarojs/runtime', 'document'],
navigator: ['@tarojs/runtime', 'navigator'],
requestAnimationFrame: ['@tarojs/runtime', 'requestAnimationFrame'],
cancelAnimationFrame: ['@tarojs/runtime', 'cancelAnimationFrame'],
Element: ['@tarojs/runtime', 'TaroElement'],
SVGElement: ['@tarojs/runtime', 'SVGElement'],
MutationObserver: ['@tarojs/runtime', 'MutationObserver'],
history: ['@tarojs/runtime', 'history'],
location: ['@tarojs/runtime', 'location'],
URLSearchParams: ['@tarojs/runtime', 'URLSearchParams'],
URL: ['@tarojs/runtime', 'URL'],
})
// trao-mini-runner/src/webpack/chain.ts
export const getProviderPlugin = args => {
return partial(getPlugin, webpack.ProvidePlugin)([args])
}</code></pre><p>这样,在小程序的运行时,就有了 一套高效、精简版的 DOM/BOM API。</p><h3>taro-react:小程序版的 react-dom</h3><p>在 DOM/BOM 注入之后,理论上来说,react 就可以直接运行了。</p><p>但是因为 React-DOM 包含大量浏览器兼容类的代码,导致包太大。Taro 自己实现了 react 的自定义渲染器,代码在taro-react包里。</p><p>在 React 16+ ,React 的架构如下:</p><p><img src="/img/remote/1460000044311827" alt="图片" title="图片"></p><p>最上层是 React 的核心部分 react-core ,中间是 react-reconciler,其的职责是维护 VirtualDOM 树,内部实现了 Diff/Fiber 算法,决定什么时候更新、以及要更新什么。</p><p>而 Renderer 负责具体平台的渲染工作,它会提供宿主组件、处理事件等等。例如 React-DOM 就是一个渲染器,负责 DOM 节点的渲染和 DOM 事件处理。</p><p>Taro实现了taro-react 包,用来连接 react-reconciler 和 taro-runtime 的 BOM/DOM API。是基于 react-reconciler 的小程序专用 React 渲染器,连接 @tarojs/runtime的DOM 实例,相当于小程序版的react-dom,暴露的 API 也和react-dom 保持一致。</p><p>这里涉及到一个问题:如何自定义 React 渲染器?</p><p><strong>第一步: 实现宿主配置( 实现react-reconciler的hostConfig配置)</strong></p><p>这是react-reconciler要求宿主提供的一些适配器方法和配置项。这些配置项定义了如何创建节点实例、构建节点树、提交和更新等操作。即在 hostConfig 的方法中调用对应的 Taro BOM/DOM 的 API。</p><p><strong>1. 创建形操作</strong></p><p><strong>createInstance(type,newProps,rootContainerInstance,_currentHostContext,workInProgress)。</strong></p><p>react-reconciler 使用该方法可以创建对应目标平台的UI Element实例。比如 document.createElement 根据不同类型来创建 div、img、h2等DOM节点,并使用 newProps参数给创建的节点赋予属性。而在 Taro 中:</p><pre><code>
import { document } from '@tarojs/runtime'
// 在 ReactDOM 中会调用 document.createElement 来生成 dom,
// 而在小程序环境中 Taro 中模拟了 document,
// 直接返回 `document.createElement(type)` 即可
createInstance (type, props: Props, _rootContainerInstance: any, _hostContext: any, internalInstanceHandle: Fiber) {
const element = document.createElement(type)
precacheFiberNode(internalInstanceHandle, element)
updateFiberProps(element, props)
return element
},</code></pre><p><strong>createTextInstance</strong></p><p>如果目标平台允许创建纯文本节点。那么这个方法就是用来创建目标平台的文本节点。</p><pre><code>import { document } from '@tarojs/runtime'
// Taro: 模拟的 document 支持创建 text 节点, 返回 `document.createTextNode(text)` 即可.
createTextInstance (text: string, _rootContainerInstance: any, _hostContext: any, internalInstanceHandle: Fiber) {
const textNode = document.createTextNode(text)
precacheFiberNode(internalInstanceHandle, textNode)
return textNode
},</code></pre><p><strong>2. UI树操作</strong></p><p><strong>appendInitialChild(parent, child)</strong></p><p>初始化UI树创建。</p><pre><code>// Taro: 直接 parentInstance.appendChild(child) 即可
appendInitialChild (parent, child) {
parent.appendChild(child)
},</code></pre><p><strong>appendChild(parent, child)</strong></p><p>此方法映射为 domElement.appendChild 。</p><pre><code>appendChild (parent, child) {
parent.appendChild(child)
},</code></pre><p><strong>3. 更新prop操作</strong></p><p><strong>finalizeInitialChildren</strong></p><p>finalizeInitialChildren 在组件挂载到页面中前调用,更新时不会调用。</p><p>这个方法我们下面事件注册时还会提到。</p><pre><code>finalizeInitialChildren (dom, type: string, props: any) {
updateProps(dom, {}, props)
// 提前执行更新属性操作,Taro 在 Page 初始化后会立即从 dom 读取必要信息
// ....
},</code></pre><p><strong>prepareUpdate(domElement, oldProps, newProps)</strong></p><p>这里是比较oldProps,newProps的不同,用来判断是否要更新节点。</p><pre><code>prepareUpdate (instance, _, oldProps, newProps) {
return getUpdatePayload(instance, oldProps, newProps)
},
// ./props.ts
export function getUpdatePayload (dom: TaroElement, oldProps: Props, newProps: Props){
let i: string
let updatePayload: any[] | null = null
for (i in oldProps) {
if (!(i in newProps)) {
(updatePayload = updatePayload || []).push(i, null)
}
}
const isFormElement = dom instanceof FormElement
for (i in newProps) {
if (oldProps[i] !== newProps[i] || (isFormElement && i === 'value')) {
(updatePayload = updatePayload || []).push(i, newProps[i])
}
}
return updatePayload
}</code></pre><p><strong>commitUpdate(domElement, updatePayload, type, oldProps, newProps)</strong></p><p>此函数用于更新domElement属性,下文要讲的事件注册就是在这个方法里。</p><pre><code>// Taro: 根据 updatePayload,将属性更新到 instance 中,
// 此时 updatePayload 是一个类似 `[prop1, value1, prop2, value2, ...]` 的数组
commitUpdate (dom, updatePayload, _, oldProps, newProps) {
updatePropsByPayload(dom, oldProps, updatePayload)
updateFiberProps(dom, newProps)
},
export function updatePropsByPayload (dom: TaroElement, oldProps: Props, updatePayload: any[]){
for(let i = 0; i < updatePayload.length; i += 2){ // key, value 成对出现
const key = updatePayload[i];
const newProp = updatePayload[i+1];
const oldProp = oldProps[key]
setProperty(dom, key, newProp, oldProp)
}
}
function setProperty (dom: TaroElement, name: string, value: unknown, oldValue?: unknown) {
name = name === 'className' ? 'class' : name
if (
name === 'key' ||
name === 'children' ||
name === 'ref'
) {
// skip
} else if (name === 'style') {
const style = dom.style
if (isString(value)) {
style.cssText = value
} else {
if (isString(oldValue)) {
style.cssText = ''
oldValue = null
}
if (isObject<StyleValue>(oldValue)) {
for (const i in oldValue) {
if (!(value && i in (value as StyleValue))) {
setStyle(style, i, '')
}
}
}
if (isObject<StyleValue>(value)) {
for (const i in value) {
if (!oldValue || value[i] !== (oldValue as StyleValue)[i]) {
setStyle(style, i, value[i])
}
}
}
}
} else if (isEventName(name)) {
setEvent(dom, name, value, oldValue)
} else if (name === 'dangerouslySetInnerHTML') {
const newHtml = (value as DangerouslySetInnerHTML)?.__html ?? ''
const oldHtml = (oldValue as DangerouslySetInnerHTML)?.__html ?? ''
if (newHtml || oldHtml) {
if (oldHtml !== newHtml) {
dom.innerHTML = newHtml
}
}
} else if (!isFunction(value)) {
if (value == null) {
dom.removeAttribute(name)
} else {
dom.setAttribute(name, value as string)
}
}
}</code></pre><p>上面是hostConfig里必要的回调函数的实现,源码里还有很多回调函数的实现,详见trao-react源码。</p><p><strong>第二步:实现渲染函数,类似于ReactDOM.render() 方法。可以看成是创建 Taro DOM Tree 容器的方法。</strong></p><p>源码实现详见trao-react/src/render.ts。</p><pre><code>export function render (element: ReactNode, domContainer: TaroElement, cb: Callback) {
const root = new Root(TaroReconciler, domContainer)
return root.render(element, cb)
}
export function createRoot (domContainer: TaroElement, options: CreateRootOptions = {}) {
// options should be an object
const root = new Root(TaroReconciler, domContainer, options)
// ......
return root
}</code></pre><pre><code>class Root {
public constructor (renderer: Renderer, domContainer: TaroElement, options?: CreateRootOptions) {
this.renderer = renderer
this.initInternalRoot(renderer, domContainer, options)
}
private initInternalRoot (renderer: Renderer, domContainer: TaroElement, options?: CreateRootOptions) {
// .....
this.internalRoot = renderer.createContainer(
containerInfo,
tag,
null, // hydrationCallbacks
isStrictMode,
concurrentUpdatesByDefaultOverride,
identifierPrefix,
onRecoverableError,
transitionCallbacks
)
}
public render (children: ReactNode, cb: Callback) {
const { renderer, internalRoot } = this
renderer.updateContainer(children, internalRoot, null, cb)
return renderer.getPublicRootInstance(internalRoot)
}
}
</code></pre><p>而 Root 类最后调用TaroReconciler的createContainr``updateContainer和 getPublicRootInstance 方法,实际上就是react-reconciler包里面对应的方法。</p><p>渲染函数是在什么时候被调用的呢?</p><p>在编译时,会引入插件taro-plugin-react, 插件内会调用 modifyMiniWebpackChain=> setAlias。</p><pre><code>// taro-plugin-react/src/webpack.mini.ts
function setAlias (ctx: IPluginContext, framework: Frameworks, chain) {
if (framework === 'react') {
alias.set('react-dom$', '@tarojs/react')
}
}</code></pre><p>这样ReactDOM.createRoot和ReactDOM.render实际上调用的就是trao-react的createRoot和render方法。</p><p>经过上面的步骤,React 代码实际上就可以在小程序的运行时正常运行了,并且会生成 Taro DOM Tree。那么偌大的 Taro DOM Tree 怎样更新到页面呢?</p><h3>从虚拟 Dom 到小程序页面渲染</h3><p>因为⼩程序并没有提供动态创建节点的能⼒,需要考虑如何使⽤相对静态的 wxml 来渲染相对动态的 Taro DOM 树。Taro使⽤了模板拼接的⽅式,根据运⾏时提供的 DOM 树数据结构,各 templates 递归地 相互引⽤,最终可以渲染出对应的动态 DOM 树。</p><p><strong>模版化处理</strong></p><p>首先,将小程序的所有组件挨个进行模版化处理,从而得到小程序组件对应的模版。如下图就是小程序的 view 组件模版经过模版化处理后的样子。⾸先需要在 template ⾥⾯写⼀个 view,把它所有的属性全部列出来(把所有的属性都列出来是因为⼩程序⾥⾯不能去动态地添加属性)。</p><p>模板化处理的核心代码在 packages/shared/src/template.ts 文件中。会在编译工程中生成 base.wxml文件,这是我们打包产物之一。</p><pre><code>// base.wxml
<wxs module="xs" src="./utils.wxs" />
<template name="taro_tmpl">
<block wx:for="{{root.cn}}" wx:key="sid">
// tmpl_' + 0 + '_' + 2
<template is="{{xs.a(0, item.nn, '')}}" data="{{i:item,c:1,l:''}}" />
</block>
</template>
....
<template name="tmpl_0_2">
<view style="{{i.st}}" class="{{i.cl}}" id="{{i.uid||i.sid}}" data-sid="{{i.sid}}">
<block wx:for="{{i.cn}}" wx:key="sid">
<template is="{{xs.a(c, item.nn, l)}}" data="{{i:item,c:c+1,l:xs.f(l,item.nn)}}" />
</block>
</view>
</template></code></pre><p>打包产生的页面代码是这样的:</p><pre><code>// pages/index/index.wxml
<import src="../../base.wxml"/>
<template is="taro_tmpl" data="{{root:root}}" /></code></pre><p>接下来是遍历渲染所有⼦节点,基于组件的 template,动态 “递归” 渲染整棵树。</p><p>具体流程为先去遍历 Taro DOM Tree 根节点的子元素,再根据每个子元素的类型选择对应的模板来渲染子元素,然后在每个模板中我们又会去遍历当前元素的子元素,以此把整个节点树递归遍历出来。</p><p><strong>hydrate Data</strong></p><p>而动态递归时需要获取到我们的 data,也就是 root。</p><p>首先,在 createPageConfig 中会对 config.data 进行初始化,赋值 {root:{cn:[]}}。</p><pre><code>
export function createPageConfig (component: any, pageName?: string, data?: Record<string, unknown>, pageConfig?: PageConfig) {
// .......
if (!isUndefined(data)) {
config.data = data
}
// .......
}</code></pre><p>React在commit阶段会调用HostConfig里的appendInitialChild方法完成页面挂载,在Taro中则继续调用:appendInitialChild —> appendChild —> insertBefore —> enqueueUpdate。</p><pre><code>// taro-react/src/reconciler.ts
appendInitialChild (parent, child) {
parent.appendChild(child)
},
appendChild (parent, child) {
parent.appendChild(child)
},
// taro-runtime/src/dom/node.ts
public appendChild (newChild: TaroNode) {
return this.insertBefore(newChild)
}
public insertBefore<T extends TaroNode> (newChild: T, refChild?: TaroNode | null, isReplace?: boolean): T {
// 忽略了大部分代码
this.enqueueUpdate({
path: newChild._path,
value: this.hydrate(newChild)
})
return newChild
}</code></pre><p>这里看到最终调用enqueueUpdate方法,传入一个对象,值为 path 和 value,而 value 值是hydrate方法的结果。</p><p>hydrate方法我们可以翻译成“注水”,函数 hydrate 用于将虚拟 DOM(TaroElement 或 TaroText)转换为小程序组件渲染所需的数据格式(MiniData)。</p><p>回想一下小程序员生的 data 里都是我们页面需要的 state,而 taro 的hydrate方法返回的 miniData 是把 state 外面在包裹上我们页面的 node 结构值。举例来看,我们一个 helloword 代码所hydrate的 miniData 如下(可以在小程序IDE中的 ”AppData“ 标签栏中查看到完整的data数据结构):</p><pre><code>{
"root": {
"cn": [
{
"cl": "index",
"cn": [
{
"cn": [
{
"nn": "8",
"v": "Hello world!"
}
],
"nn": "4",
"sid": "_AH"
},
{
"cn": [
{
"nn": "8",
"v": "HHHHHH"
}
],
"nn": "2",
"sid": "_AJ"
},
{
"cl": "blue",
"cn": [
{
"nn": "8",
"v": "Page bar: "
},
{
"cl": "red",
"cn": [
{
"nn": "8",
"v": "red"
}
],
"nn": "4",
"sid": "_AM"
}
],
"nn": "4",
"sid": "_AN"
}
],
"nn": "2",
"sid": "_AO"
}
],
"uid": "pages/index/index?$taroTimestamp=1691064929701"
},
"__webviewId__": 1
}
</code></pre><p>这里的字段含义解释一下 :(我想这里缩写是可能尽可能让每一次setData的内容更小。)</p><pre><code>Container = 'container',
Childnodes = 'cn',
Text = 'v',
NodeType = 'nt',
NodeName = 'nn',
// Attrtibutes
Style = 'st',
Class = 'cl',
Src = 'src</code></pre><p>我们获取到以上的 data 数据,去执行enqueueUpdate函数,enqueueUpdate函数内部执行performUpdate函数,performUpdate函数最终执行 ctx.setData,ctx 是小程序的实例,也就是执行我们熟悉的 setData 方法把上面hydrate的 miniData赋值给 root,这样就渲染了小程序的页面数据。</p><pre><code>// taro-runtime/src/dom/root.ts
public enqueueUpdate (payload: UpdatePayload): void {
this.updatePayloads.push(payload)
if (!this.pendingUpdate && this.ctx) {
this.performUpdate()
}
}
public performUpdate (initRender = false, prerender?: Func) {
// .....
while (this.updatePayloads.length > 0) {
const { path, value } = this.updatePayloads.shift()!
if (path.endsWith(Shortcuts.Childnodes)) {
resetPaths.add(path)
}
data[path] = value
}
// .......
if (initRender) {
// 初次渲染,使用页面级别的 setData
normalUpdate = data
}
// ........
ctx.setData(normalUpdate, cb)
}
</code></pre><p>整体流程可以概括为:当在React中调用 this.setState 时,React内部会执行reconciler,进而触发 enqueueUpdate 方法,如下图:</p><p><img src="/img/remote/1460000044311828" alt="图片" title="图片"></p><h3>事件处理</h3><p><strong>事件注册</strong></p><p>在HostConfig接口中,有一个方法 finalizeInitialChildren,在这个方法里会调用updateProps。这是挂载页面阶段时间的注册时机。updateProps 会调用 updatePropsByPayload 方法。</p><pre><code>finalizeInitialChildren (dom, type: string, props: any) {
updateProps(dom, {}, props)
//....
},</code></pre><p>在HostConfig接口中,有一个方法 commitUpdate,用于在react的commit阶段更新属性:</p><pre><code>commitUpdate (dom, updatePayload, _, oldProps, newProps) {
updatePropsByPayload(dom, oldProps, updatePayload)
updateFiberProps(dom, newProps)
},</code></pre><p>进一步的调用方法:updatePropsByPayload => setProperty => setEvent。</p><pre><code>// taro-react/src/props.ts
function setEvent (dom: TaroElement, name: string, value: unknown, oldValue?: unknown) {
const isCapture = name.endsWith('Capture')
let eventName = name.toLowerCase().slice(2)
if (isCapture) {
eventName = eventName.slice(0, -7)
}
const compName = capitalize(toCamelCase(dom.tagName.toLowerCase()))
if (eventName === 'click' && compName in internalComponents) {
eventName = 'tap'
}
// 通过addEventListener将事件注册到dom中
if (isFunction(value)) {
if (oldValue) {
dom.removeEventListener(eventName, oldValue as any, false)
dom.addEventListener(eventName, value, { isCapture, sideEffect: false })
} else {
dom.addEventListener(eventName, value, isCapture)
}
} else {
dom.removeEventListener(eventName, oldValue as any)
}
}
</code></pre><p>进一步的看看dom.addEventListener做了什么?addEventListener是类TaroEventTarget的方法:</p><pre><code>
export class TaroEventTarget {
public __handlers: Record<string, EventHandler[]> = {}
public addEventListener (type: string, handler: EventHandler, options?: boolean | AddEventListenerOptions) {
type = type.toLowerCase()
// 省略很多代码
const handlers = this.__handlers[type]
if (isArray(handlers)) {
handlers.push(handler)
} else {
this.__handlers[type] = [handler]
}
}
}</code></pre><p>可以看到事件会注册到dom对象上,最终会放入到 dom 内部变量 _handlers 中保存。</p><p><strong>事件触发</strong></p><pre><code>// base.wxml
<template name="tmpl_0_7">
<view
hover-class="{{xs.b(i.p1,'none')}}"
hover-stop-propagation="{{xs.b(i.p4,!1)}}"
hover-start-time="{{xs.b(i.p2,50)}}"
hover-stay-time="{{xs.b(i.p3,400)}}"
bindtouchstart="eh"
bindtouchmove="eh"
bindtouchend="eh"
bindtouchcancel="eh"
bindlongpress="eh"
animation="{{i.p0}}"
bindanimationstart="eh"
bindanimationiteration="eh"
bindanimationend="eh"
bindtransitionend="eh"
style="{{i.st}}"
class="{{i.cl}}"
bindtap="eh"
id="{{i.uid||i.sid}}"
data-sid="{{i.sid}}"
>
<block wx:for="{{i.cn}}" wx:key="sid">
<template is="{{xs.a(c, item.nn, l)}}" data="{{i:item,c:c+1,l:xs.f(l,item.nn)}}" />
</block>
</view>
</template></code></pre><p>上面是base.wxml其中的一个模板,可以看到,所有组件中的事件都会由 eh 代理。在createPageConfig时,会将 config.eh 赋值为 eventHandler。</p><pre><code>// taro-runtime/src/dsl/common.ts
function createPageConfig(){
const config = {...} // config会作为小程序 Page() 的入参
config.eh = eventHandler
config.data = {root:{cn:[]}}
return config
}</code></pre><p>eventHandler 最终会触发 dom.dispatchEvent(e)。</p><pre><code>// taro-runtime/src/dom/element.ts
class TaroElement extends TaroNode {
dispatchEvent(event){
const listeners = this.__handlers[event.type] // 取出回调函数数组
for (let i = listeners.length; i--;) {
result = listener.call(this, event) // event是TaroEvent实例
}
}
}</code></pre><p>至此,react 代码终于是可以完美运行在小程序环境中。</p><p>还要提到一点的是,Taro3 在 h5 端的实现也很有意思,Taro在 H5 端实现一套基于小程序规范的组件库和 API 库,在这里就不展开说了。</p><h2>总结</h2><p>Taro 3从之前的重编译时,到现在的重运行时,解决了架构问题,可以用 react、vue 甚至 jQuery 来写小程序,但也带来了一些性能问题。</p><p>为了解决性能问题,Taro 3 也提供了预渲染和虚拟列表等功能和组件。</p><p>但从长远来看,计算机硬件的性能越来越冗余,如果在牺牲一点可以容忍的性能的情况下换来整个框架更大的灵活性和更好的适配性,并且能够极大的提升开发体验,也是值得的。</p><p>(本文作者:孟祥辉)</p><p><img width="732" height="246" src="https://segmentfault.com/img/bVc86qd" alt="图片" title="图片"></p>
Vue 不定高展开动效及其原理
https://segmentfault.com/a/1190000044282655
2023-10-08T10:37:31+08:00
2023-10-08T10:37:31+08:00
哈啰技术
https://segmentfault.com/u/hellotech
1
<h2>使用场景</h2><p>在大多数 APP 中,都有问答模块,问答模块的静态页面开发并不复杂,也没有特殊的交互。唯一有一点难度应该是回答部分的展开特效。</p><ul><li>展开时,需要从上往下将回答部分的 div 慢慢撑开,上面的箭头也要有旋转的特效。</li><li>收回时,需要从下往上将回答部分的 div 慢慢缩小,上面的箭头也要有旋转的特效。</li></ul><p>对于一般的展开、隐藏特效,只需要在对应元素的 height 上面增加过渡效果即可。但问题是:不知道对应的 div 的高度,其高度是内部的元素自动撑开的,此时直接在 height 属性上面添加过渡效果会失效(后面会说明原因)。</p><p>对于箭头的旋转,则只需要在箭头元素的 transform 上面增加过渡效果,然后让其旋转 180 度(rotateZ(180deg))即可,这个比较好实现。</p><h2>背景</h2><p>今天做需求时,正好需要做这种特效。先介绍一下列表的数据结构和其 DOM 结构。</p><p>列表数据结构如下:</p><pre><code>// QaItem 表示问答的每一项
interface QaItem {
Q: string; // 问题
A: string; // 回答
show: boolean; // 是否展示
}
// QaList 表示问答列表
type QaList = QaItem[];</code></pre><p>项目中并未使用 TypeScript,这里用 interface 是为了方便理解。</p><p>DOM 结构(Vue 版本)如下:</p><pre><code><div class="qa panel">
<div class="qa__title">
常见问题
</div>
<div class="list-qa">
<div
v-for="(item, ind) in qaList"
:key="ind"
class="list-qa__item"
>
<div class="list-qa__question">
<span>{{ item.Q }}</span>
<span class="list-qa__question__arrow" />
</div>
<span class="list-qa__answer">
{{ item.A }}
</span>
</div>
</div>
</div></code></pre><p>上面的结构简化了一些交互逻辑和展示逻辑,默认问答列表的每一项都会展示。最外层包裹了一层 div,上面是标题,下面是问答列表,问答列表的每一项包括问题、箭头 icon 和答案。</p><p><img src="/img/remote/1460000044282657" alt="图片" title="图片"></p><p>实现因为项目使用的框架是 Vue,所以以 Vue 为例,来分析一下如何实现它,以及其实现的原理。</p><p>回答是否展示,可以用一个变量控制,这里是 qaItem 的 show 属性。使用 v-show 实现,因为用户可能会多次点击箭头,导致回答频繁地展示或隐藏。</p><pre><code><div
v-for="(item, ind) in qaList"
:key="ind"
class="list-qa__item"
>
<!-- 。。。省略不相关元素。。。 -->
<span
v-show="item.show"
class="list-qa__answer"
>
{{ item.A }}
</span>
</div></code></pre><h3>transition 组件</h3><p>在 Vue 中,可以使用 transition 组件来为元素添加动态效果。transition 组件让我们可以为使用条件渲染(v-if、v-show)的元素添加进入、离开时的过渡效果。</p><pre><code><div id="demo">
<button v-on:click="show = !show">
Toggle
</button>
<transition name="fade">
<p v-if="show">hello</p>
</transition>
</div></code></pre><pre><code>.fade-enter-active, .fade-leave-active {
transition: opacity .5s;
}
.fade-enter, .fade-leave-to /* .fade-leave-active below version 2.1.8 */ {
opacity: 0;
}</code></pre><p>这样在 name 为 fade 的 transition 组件包裹的 p 标签展示和隐藏时,会有一个 0.5s 的淡入淡出效果。</p><h3>过渡效果原理</h3><p>在展示时,p 标签的 opacity(透明度)会从 0(.fade-enter 类选择器中设定的值)开始增加,经过 0.5s 之后,增加至 opacity: 1(元素默认的透明度 opacity 为 1)。</p><p>在隐藏时,p 标签的 opacity(透明度)会从 1 开始减少,经过 0.5s 之后,减少至 opacity: 0(.fade-leave-to 类选择器中设定的值)。</p><p>这样就实现了淡入淡出效果。</p><p>同样的,如果我们想让一个元素展示时高度从 0 开始增加,经过某一个时间,达到具体的值;隐藏时高度从该具体值开始减少,经过某一个时间,达到 0。这样就能实现我们前面需要的效果。</p><p>我们可以用 css transition 为某一个元素设置过渡效果,过渡效果作用在这个元素的某个属性上、过渡效果的时长等。</p><pre><code>.box {
transition: height 1s;
}</code></pre><p>上面代表为 class 为 box 的元素设置了过渡效果,作用在它的 height 属性上面,过渡效果的时长为 1s。当该元素的高度从某一个值变化到另一个值时,就会有一个长为 1s 的过渡效果。</p><p>过渡效果的本质是:当作用的属性的值变化时,并不会立即从一个值变为另一个值,而是在变化的过程中,将中间状态呈现出来。</p><p>例如:设置了过渡效果的元素的高度(height)从 0 变化到 100px 时,并不是直接从 0 变化到 100px 的,其变化过程是一个连续的状态,从 0 到 1px,从 1px 到 2px······直到 100px。把中间的高度展现出来,就可以让用户看到过渡效果。</p><p>再例如:设置了过渡效果的元素的透明度(opacity)从 0 变化到 1 时,并不是直接从 0 变化到 1 的,其变化过程也是一个连续的状态,从 0 到 0.1,从 0.1 到 0.2······直到 opacity 为1。这样用户就可以看到一个元素从透明状态逐渐变得清晰。当然,并不一定就是从 0 变化到 0.1,然后从 0.1 变化到 0.2,这个过程是一个连续的过程,它的值在慢慢增加,增量是多少并不重要。</p><p>需要实现过渡效果,就需要一个起始态和一个终止态,浏览器能够从起始态逐步过渡到终止态。也就是从起始态到终止态之间的部分是连续的,是可以计算的,这样浏览器才能把中间的状态给我们呈现出来。</p><p>再回到之前的问题:不知道 div 的高度,其高度是内部的元素自动撑开的,此时直接在 height 属性上面添加过渡效果会失效。</p><p><strong>为什么会失效?</strong><br>对于一个 div,如果它的高度是由子元素撑开的,那么它的 css 样式 height 属性的值为 auto。从 0 变到 auto,或者从 auto 变到 0,其中间状态都是不可计算的,浏览器没发给我们展示出中间状态,所以我们看不到过渡效果。</p><p>既然从 0 变到 auto,或者从 auto 变到 0,中间状态无法计算,那我们可以显式地告诉浏览器一个数值,应该从 0 变到多少,或者从多少变到 0,让浏览器可以计算出中间状态,这样不就能看到过渡效果了吗?</p><h3>解决</h3><p>当展开时,起始态为 0,我们通过 getComputedStyle(element).height 得到元素的具体高度 x(终止态)。给元素设置 transition 属性,然后将元素的高度从 0 变到 x,这样就能实现展开的动效了。</p><pre><code><transition
name="slide"
@before-enter="beforeEnter"
@enter="enter"
@after-enter="afterEnter"
@before-leave="beforeLeave"
@leave="leave"
@after-leave="afterLeave"
>
<span
v-show="item.show"
class="list-qa__answer"
>{{ item.A }}</span>
</transition></code></pre><pre><code>beforeLeave(el) {
// 给元素设置过渡效果
el.style.transition = '0.3s height ease-in-out';
// 高度变化时,让其内容隐藏
el.style.overflow = 'hidden';
},
leave(el) {
el.style.height = 'auto';
// 设置高度为具体的值
el.style.height = window.getComputedStyle(el).height;
// 强制浏览器回流,否则浏览器会合并两次元素的高度更改(回流重绘的知识)
el.offsetHeight;
el.style.height = '0px';
},
afterLeave(el) {
// el.style.height = null;
// 收尾工作,展示完过渡效果之后,设为原来的值
el.style.transition = '';
el.style.overflow = 'visible';
},</code></pre><p>这里需要给元素设置 overflow: hidden,在元素高度小于内部内容的高度时,才会隐藏内容。</p><p>同样地,隐藏时先通过 getComputedStyle(element).height 得到元素的具体高度 x(起始态),给元素设置 transition 属性,然后将元素的高度从 x 变到 0,这样就能实现隐藏的动效了。</p><pre><code>beforeEnter(el) {
// 给元素设置过渡效果
el.style.transition = '0.3s height ease-in-out';
// 高度变化时,让其内容隐藏
el.style.overflow = 'hidden';
},
enter(el) {
el.style.height = 'auto';
// 保存元素原来的高度
const endWidth = window.getComputedStyle(el).height;
el.style.height = '0px';
// 强制浏览器回流,否则浏览器会合并两次元素的高度更改(回流重绘的知识)
el.offsetHeight;
el.style.height = endWidth;
},
afterEnter(el) {
// el.style.height = null;
// 收尾工作,展示完过渡效果之后,设为原来的值
el.style.transition = '';
el.style.overflow = 'visible';
},</code></pre><p>箭头的旋转动效就比较简单了。先设置过渡效果,然后只需要在点击箭头的时候,动态为这个元素添加一个类名,让其旋转属性生效(rotateZ(180deg));当再一次点击的时候,去掉这个类名就好了。</p><pre><code><span
class="list-qa__question__arrow"
:class="{'list-qa__question__rotate-arrow': !item.show}"
@click="onClickPromblem(ind)"
/></code></pre><pre><code>onClickPromblem(index) {
const qaItem = this.qaList[index];
this.$set(qaItem, 'show', !qaItem.show);
},</code></pre><pre><code>list-qa__question__arrow {
width: 12px;
height: 12px;
background: url(https://m.hellobike.com/resource/helloyun/21588/RIBiB_SketchPngf4c3c2445f4522fe182c1d02d45a6201fa03ecfb14550a3269204012abdcfa09) center no-repeat;
transition: transform .4s;
}
list-qa__question__rotate-arrow {
transform: rotateZ(180deg);
transition: transform .4s;
}</code></pre><p>(本文作者:旷卓)</p><p><img width="732" height="246" src="https://segmentfault.com/img/bVc86qd" alt="图片" title="图片"></p>
一文带你了解 Web Worker - 前端的“多线程”
https://segmentfault.com/a/1190000044258408
2023-09-25T15:56:44+08:00
2023-09-25T15:56:44+08:00
哈啰技术
https://segmentfault.com/u/hellotech
1
<h2>前言</h2><p>众所周知,JavaScript 采用的是单线程模型,即所有任务都在一个线程上完成,一次只能做一件事情。但单线程意味着所有的任务都需要排队,前一个任务结束了,才会执行后一个任务。如果一个任务耗费了太长的时间,后一个任务就一直无法执行。体现在浏览器里就是浏览器卡住了,无法操作。</p><p>试一下,把下面的代码粘贴到浏览器console里面,会发现浏览器卡住无法操作。</p><pre><code>// 计算斐波那契数列
const fibonacci = (n) => {
count += 1;
if (n === 0) return 0;
if (n === 1) return 1;
if (n > 1) return fibonacci(n - 1) + fibonacci(n - 2)
}
const time0 = new Date().getTime();
console.log('time0', time0);
fibonacci(40);
const time1 = new Date().getTime();
console.log('time1', time1);
const duration = time1 - time0;
console.log('duration', duration);
// const f = (n) => n > 1 ? f(n - 1) + f(n -2) : n</code></pre><h3>js为什么是单线程的?</h3><p>JavaScript 可以操纵 DOM ,如果在修改元素属性同时渲染界面,渲染线程前后获得的元素数据可能不一致。为了防止渲染出现不可预期的结果,浏览器设置GUI渲染线程与JS引擎为互斥关系,当JS引擎执行时GUI线程会被挂起,GUI更新则会被保存在一个队列中等到JS引擎线程空闲时立即被执行。</p><h3>那么问题来了</h3><p>如果JS引擎的计算量过大,GUI的更新会进入队列,页面无反应,卡顿感就产生了。</p><p>所以,我们要尽量避免使用JS执行大量计算。但在日常的需求中我们不可避免的会有js处理大量计算的场景,这时候 Web Worker 就派上了用场。</p><h2>概述</h2><h3>什么是Web Worker</h3><p>Web Worker 是HTML5标准的一部分,他定义了一整套的api允许开发者在js线程之外独立出一个单独的线程,处理额外的js代码。</p><p>因为是独立的线程,Web Worker 可以和主线程js同时运行,互不影响。我们可以把复杂且耗时的计算交给 Web Worker 进行,待 Worker 计算完成之后,再交由主线程 js 去消费。这样主线程仅需要关心业务逻辑和页面渲染,不需要把时间耗费在计算上,流畅度可以大大提升。</p><h3>Web Worker 可以干什么,有什么限制</h3><p>Web Worker 可以认为是一个独立的js环境,你可以在里面运行任何你喜欢的代码, 除了操作dom或者运行 window 对象中的一些方法和属性。</p><p>实际上 Web Worker 没有 window 的概念(也没有 document 对象,所以无法操作 Dom),其运行上下文环境是 WorkerGlobalScope 对象的实例,通过 self 关键字暴露出来。</p><p>WorkerGlobalScope 对象上的可用属性是 window 对象的子集,其中有些属性和 window 一致,而有些属性则并不完全相同。</p><h2>Web Worker 专用工作者线程</h2><h3>Worker 线程使用有些注意点</h3><p>1.同源限制Worker <br>线程执行的脚本文件(即 上述代码的 worker.js)必须和主线程的文件同源,从其他源加载 Worker 脚本文件会报错。</p><p>2.文件限制Worker <br>线程无法读取本地文件,文件需要通过主线程读取到文件之后再传输给 Worker。</p><p>3.DOM操作限制<br>上面提到了,Worker 和主线程在不同的上下文环境运行,无法读取主线程所在的 DOM 对象以及 document 和 window 对象,但 Worker 的全局对象 WorkerGlobalScope 提供了对navigator、location、setTimeOut等浏览器API的访问能力,尽管其中的有些API的属性和 window 上并不相同。</p><p>4.通信限制<br>Worker 和主线程无法直接通信,需要通过 postMessage 或者 BroadcastChannel 进行通信。</p><h3>创建 Worker</h3><p>可以通过将文件路径提供给 Worker 构造函数的方式来创建 专用工作者。options是可选的配置,可以配置 Worker 的一些属性。</p><pre><code>// 主线程
const worker = new Worker(jsUrl, options);</code></pre><p>options 参数</p><table><thead><tr><th>参数名称</th><th>描述</th><th>类型</th></tr></thead><tbody><tr><td>name</td><td>worker线程的名称,可以在工作者线程中通过 self.name 获取到字符串标识</td><td>string</td></tr><tr><td>type</td><td>表示加载脚本的方式,可以是 'classic' 或者'module'。'classic'将脚本作为普通脚本来执行,'module'将脚本作为模块来执行。</td><td>'classic'|'module'</td></tr><tr><td>credentials</td><td>当type为'module'时,指定如何获取与传输凭证数据(cookie)相关的Web Worker脚本,与fetch的 credentials 属性一致。在type为'classic'时默认为'omit'。</td><td>'omit'|'same-origin'|'include'</td></tr></tbody></table><h3>关于 Worker 的初始化脚本</h3><p>如果是普通项目,直接把初始化文件放在一个文件夹下,可以直接创建 Worker。</p><pre><code>const worker = new Worker('worker.js');
</code></pre><p>在 Webpack 项目中,我们需要添加各种 loader 支持新技术,创建 Worker 需要使用worker-loader:</p><pre><code>// webpack 4.0
import Worker from 'worker-loader!./worker';
const worker = new Worker();</code></pre><p>但Webpack 5.0之后,我们不需要 worker-loader了,于是我们可以这么创建:</p><pre><code>const worker = new Worker(new URL('./worker.js', import.meta.url));</code></pre><p>此处的 new URL(),可以约等于 nodejs 中的 path.resolve(baserul + './worker.js')。</p><p>还有一个简单的解决方案:把 worker 脚本放到 public 文件夹下,这样打包产物就和 worker 脚本在同一个文件夹下,可以正常初始化 Worker。</p><p>除了使用脚本文件创建 Worker 之外,我们还可以使用 行内js 来创建工作者线程。通过 Blob 对象 URL 我们可以更快的初始化工作者线程,因为没有网络延迟。</p><pre><code>// 创建代码字符串
const workerScriptStr = `
self.onmessage = (e) => {
console.log(e.data);
postMessage('get message from main thread');
}
`;
// 基于脚本字符串生成Blob对象
const workerBlob = new Blob([workerScriptStr]);
// 基于Blob实例创建对象URL
const workerBlobUrl = URL.createObjectURL(workerBlob);
// 基于对象URL创建专用工作者线程
const worker = new Worker(workerBlobUrl);
worker.postMessage('main thread send message');
// main thread send message</code></pre><p>上面的例子是把步骤分解开,一步步的创建 Worker,可以写一块:</p><pre><code>const worker = new Worker(URL.createObjectURL(new Blob([`self.onmessage =
({data}) => console.log(data);`])));
worker.postMessage('main thread send message');
// main thread send message</code></pre><h3>ES Module</h3><p>在初始化 Worker 时,如果不传第二个配置参数,默认执行脚本的方式为 'classic',此时在脚本里仅可以通过 Worker 的全局对象 WorkerGlobalScope 提供的 importScripts 方法引用在线脚本。</p><p>如果使用 import 关键字引入,会报错 Cannot use import statement outside a module 不允许在 module 外使用 import。</p><pre><code>// main.js
const worker = new Worker('worker.js');
// worker.js
// import { sum } from 'lodash'; // Error: Cannot use import statement outside a module
importScripts('https://cdn.jsdelivr.net/npm/lodash@4.17.21/lodash.min.js');
_.sum([1, 2]);
...</code></pre><p>但如果在创建时指定了 type 为 'module':</p><pre><code>// main.js
const worker = new Worker('worker.js', { type: 'module' });
// worker.js
import { sum } from 'lodash';
sum([1, 2]);
...</code></pre><p>则不会报错,从而可以愉快的使用按需导入能力了。</p><p>由于 Web Worker 是一个独立的线程,所以理论上,你可以在Web Worker 里再启用一个 Web Worker 子线程,在有多个CPU核心的时候,使用多个子线程可以实现并行计算,这里就不展开了。</p><h3>与 Web Worker 通信</h3><p>与工作者线程通信都是通过 postMessage 方法发送消息,通过 onmessage 事件处理函数来接受消息。数据传输的方式是通过 结构化克隆算法 克隆数据,传递数据副本。</p><p>浏览器支持另一种性能更好的对象传输方式 可转移对象(Transferable objects) ,通过可转移对象,资源的所有权会从一个上下文直接转移到另一个上下文,而并不会经过克隆。传输后,原始对象将不可用;它将不再指向转移后的资源,并且任何尝试读取或者写入的操作都将抛出异常。</p><p>与主线程的数据交互方式如下图所示:</p><p><img src="/img/remote/1460000044258410" alt="图片" title="图片"></p><p>试一下:</p><pre><code>// main.js
const worker = new Worker(new URL('worker.js', import.meta.url), { type: 'module' });
worker.onmessage = (e) => {
// 接收来自 worker 的消息
setInfo(e.data);
}
// 发送消息给 worker
worker.postMessage('message from main thread');
// 可转移对象
// 创建一个 8MB 的文件并填充
const uInt8Array = new Uint8Array(1024 * 1024 * 8).map((v, i) => i);
console.log(uInt8Array.byteLength); // 8388608
// 将底层 buffer 传递给 worker
worker.postMessage(uInt8Array, [uInt8Array.buffer]);
console.log(uInt8Array.byteLength); // 0
// worker.js
import { sum } from 'lodash';
// 如果是 classic 模式,则需要通过 improtscripts 来引入网络脚本
// importScripts('https://cdn.jsdelivr.net/npm/lodash@4.17.21/lodash.min.js');
// 接收来自主线程的消息
onmessage = (e) => {
console.log(e.data);
const temp = Array.from(e.data).map((e) => +e);
// 将计算结果发送给主线程
postMessage(sum(temp));
};</code></pre><p>备注:像 Int32Array 和 Uint8Array 等类型化数组(TypedArray)是可序列化的(Serializable object),但是不能转移。然而,它们的底层缓冲区是一个 ArrayBuffer,它是一个可转移对象。我们可以在数据参数中发送 uInt8Array.buffer,但是不能在传输数组中发送 uInt8Array。</p><p>除了 postMessage 方法发送消息之外,还有另外一种方式,可以发送消息。</p><h3>BroadcastChannel</h3><p>BroadcastChannel 从字面意思上理解是广播频道,他可以让同源页面的浏览器上下文来订阅它。</p><p>它允许 同源的 不同浏览器窗口、tab页、frame 或者 iframe 下的不同文档之间互相通信。通过触发 message 事件,消息可以广播到所有监听了该频道的 BroadcastChannel 对象。</p><p>此特性在 Web Worker 中可用,由于初始化 Worker 的脚本和主线程是同源的,在 Web Worker 中广播的消息,主线程可以监听到,反之亦然。</p><p>试一下:</p><pre><code>// 初始化具名频道
const channel = new BroadcastChannel('bm channel');
// 广播消息,发送的消息自己接收不到,其他源可以接收到
channel.postMessage('全场两元,通通两元');
// 接收其他源发送的消息
channel.onmessage = (e) => {
console.log('get message from other broadcast', e.data);
};</code></pre><p>尝试一下:任意打开两个相同的页面,把上面的代码分别粘贴到浏览器的console调试里面,在一个页面调用一下 channel 的 postMessage 方法,在另一个页面看一下,发现消息可以打印出来。</p><h3>工作者线程的生命周期</h3><p>1.初始化<br>调用 Worker() 构造函数是一个专用工作者线程生命周期的起点。调用之后,它会初始化对工作者线程脚本的请求,并把 Worker 对象返回给父上下文。虽然父上下文中可以立即使用这个 Worker 对象,但与之关联的工作者线程可能还没有创建,因为存在请求脚本的网格延迟和初始化延迟。</p><p>初始化时,虽然工作者线程脚本尚未执行,但可以先把要发送给工作者线程的消息加入队列。这些 消息会等待工作者线程的状态变为活动,再把消息添加到它的消息队列。</p><p>2.活动中<br>创建之后,专用工作者线程就会伴随页面的整个生命期而存在,除非自我终止 self.close() 或通过外部终止 worker.terminate()。即使线程脚本已运行完成,线程的环境仍会存在。只要工作者线程仍存在,与之关联的 Worker 对象就不会被当成垃圾收集掉。</p><p>3.终止<br>在整个生命周期中,一个专用工作者线程只会关联一个网页(Web 工作者线程规范称其为一个文档)。除非明确终止(通过 self.close() 或者worker.terminate() ),否则只要关联文档存在,专用工作者线程就会存在。如果浏览器离开网页(通过导航或关闭标签页或关闭窗口),它会将与其关联的工作者线程标记为终止,它们的执行也会立即停止。</p><h2>Shared Worker 共享工作者线程</h2><p>Shared Worker 与 Web Worker 类似,但可以被多个可信任的执行上下文访问。例如, 同源的两个标签页可以访问同一个共享工作者线程。SharedWorker 与 Worker 的消息接口稍有不同, 包括外部和内部。</p><p>共享线程适合开发者希望通过在多个上下文间共享线程减少计算性消耗的情形。比如,可以用一个 共享线程管理多个同源页面 WebSocket 消息的发送与接收。共享线程也可以用在同源上下文希望通过一个线程通信的情形。</p><p>从行为上讲,共享工作者线程可以看作是专用工作者线程的一个扩展。线程创建、线程选项、安全限制和 importScripts() 的行为都是相同的。与专用工作者线程一样,共享工作者线程也在独立执行上下文中运行,也只能与其他上下文异步通信。</p><h3>创建 Shared Worker</h3><p>Shared Worker 线程的创建和使用与 Worker 类似,事件和方法基本一样。不同点在于主线程与Shared Worker 是通过 MessagePort 建立的链接,数据通讯方法都挂载在 SharedWorker.port上。</p><p>另外,如果你采用 addEventListener 来接收 message 事件,那么在主线程初始化SharedWorker() 后,还要调用 SharedWorker.port.start() 方法来手动开启端口。</p><p>试一下:</p><pre><code>// main.js
const sharedWorker = new SharedWorker('sharedWorker.js', '宝明的 shared worker ~');
// 接收到共享工作者线程消息时触发
sharedWorker.port.onmessage = (e) => {
console.log('get shared worker message: ', e.data);
}
// 向共享工作者线程发消息
sharedWorker.port.postMessage('message for shared worker');
// sharedWorker.js
onconnect = (e) => {
// 页面与shared worker 创建链接时触发
console.log('shared worker connect ~~', e);
let port = e.ports[0];
// 接收到页面传入的消息时触发
port.onmessage = (p) => {
console.log('shared worker get message', p.data);
}
}</code></pre><h3>共享工作者的生命周期</h3><p>共享工作者线程的生命周期具有与专用工作者线程相同的阶段的特性。不同之处在于,专用工作者线程只跟一个页面绑定,而共享工作者线程只要还有一个上下文连接就会持续存在。</p><p>你可以在创建共享工作者线程时,指定不同的线程名,来强制开启多个共享工作者线程。</p><h3>利用 Shared Worker 手动实现 BroadcastChannel 广播</h3><p>1.主线程创建 Shared Worker</p><pre><code>// main.js
if (window.SharedWorker) {
const sharedWorker = new SharedWorker('sharedWorker.js', '宝明的 shared worker ~');
sharedWorker.port.postMessage('全场2元,通通两元;买不了吃亏,买不了上当');
sharedWorker.port.onmessage = (e) => {
console.log('-- 接收到其他页面sharedWorker的广播消息 --',e.data);
}
}</code></pre><p>2.sharedWorker.js 处理连入的线程<br>因为要向其他连入的线程发送消息,所以要将所有连入的线程全都维护起来。</p><pre><code>// sharedWorker.js
/** 创建一个port池,把所有的 port 缓存起来,用于广播消息 */
const portPool = [];
onconnect = (e) => {
console.log('shared worker connect ~~', e);
let port = e.ports[0];
// 将当前 port 缓存进 portPool
portPool.push(port);
// 接收到页面传入的消息时触发
port.onmessage = (p) => {
// 向自己发消息
port.postMessage(p.data);
}
}
</code></pre><p>3.向其他页面发送消息<br>由于是广播消息,所以在发送消息时需要将自身排除在外。</p><pre><code>// 向其他页面发送消息
const boradcastMessage = (msg, selfPort) => {
portPool.forEach((p) => {
if (p !== selfPort) {
// 向其他页面广播消息
p.postMessage(msg);
}
});
};</code></pre><p>4.处理失效线程<br>共享线程与父上下文的启动和关闭不是对称的。每个新 SharedWorker 连接都会触发一个事件,但没有事件对应断开 SharedWorker 实例的连接(如页面关闭)。</p><p>在前面的例子中,随着与相同共享线程连接和断开连接的页面越来越多,portPool 线程池中会受到死端口的污染,没有办法识别它们。一个解决方案是在销毁页面时,明确发送卸载消息,让共享线程有机会清除死端口。</p><pre><code>// 清空无效的port
if (e.data === 'NEED CLOSE') {
const index = portPool.findIndex((p) => p === port);
portPool.splice(index, 1);
}
// main.js 页面关闭时
sharedWorker.port.postMessage('NEED CLOSE');</code></pre><p>5.完整代码</p><pre><code>// main.js
if (window.SharedWorker) {
const sharedWorker = new SharedWorker('sharedWorker.js', '宝明的 shared worker ~');
sharedWorker.port.postMessage('全场2元,通通两元;买不了吃亏,买不了上当');
sharedWorker.port.onmessage = (e) => {
console.log('-- 接收到其他页面sharedWorker的广播消息 --',e.data);
}
}
document.addEventListener('beforeunload', () => {
sharedWorker.port.postMessage('NEED CLOSE');
})
// sharedWorker.js
/** 创建一个port池,把所有的 port 缓存起来,用于广播消息 */
const portPool = [];
// 向其他页面发送消息
const boradcastMessage = (msg, selfPort) => {
portPool.forEach((p) => {
if (p !== selfPort) {
// 向其他页面广播消息
p.postMessage(msg);
}
});
};
onconnect = (e) => {
console.log('shared worker connect ~~', e);
let port = e.ports[0];
// 将当前 port 缓存进 portPool
portPool.push(port);
// 接收到页面传入的消息时触发
port.onmessage = (p) => {
// 向自己发消息
// port.postMessage(p.data);
// 向其他页面发送消息
boradcastMessage(p.data, port);
// 清空无效的port
if (e.data === 'NEED CLOSE') {
const index = portPool.findIndex((p) => p === port);
portPool.splice(index, 1);
}
}
}</code></pre><h2>调试 Worker</h2><h3>调试 Web Worker</h3><p>Web Worker 可以在当前页面的 Source 中进行查看。</p><p><img src="/img/remote/1460000044258411" alt="图片" title="图片"></p><h3>调试 Shared Worker</h3><p>Shared Worker 需要在谷歌调试中调试,链接:chrome://inspect/#workers</p><p><img src="/img/remote/1460000044258412" alt="图片" title="图片"></p><p>1.打开谷歌任务管理器,记录进程id</p><p><img src="/img/remote/1460000044258413" alt="图片" title="图片"></p><p><img src="/img/remote/1460000044258414" alt="图片" title="图片"></p><p>2.打开 mac 的活动监视器,找到进程</p><p><img src="/img/remote/1460000044258415" alt="图片" title="图片"></p><p>点击取样</p><p><img src="/img/remote/1460000044258416" alt="图片" title="图片"></p><p>我们可以看到,打开了两个相同的页面,有两个专用工作者线程,而仅有一个共享工作者线程,因为初始化多个同名共享工作者线程,会共享同一个实例。</p><h2>总结</h2><p>工作者线程可以运行异步 JavaScript 而不阻塞用户界面。这非常适合复杂计算和数据处理,特别是需要花较长时间因而会影响用户使用网页的处理任务。工作者线程有自己独立的环境,只能通过异步消息与外界通信。</p><p>工作者线程可以是专用线程、共享线程。专用线程只能由一个页面使用,而共享线程则可以由同源的任意页面共享。</p><p>(本文作者:陈宝明)</p><p><img width="732" height="246" src="https://segmentfault.com/img/bVc86qd" alt="图片" title="图片"></p>
ElasticSearch节点嗅探机制实践
https://segmentfault.com/a/1190000044232399
2023-09-19T15:53:11+08:00
2023-09-19T15:53:11+08:00
哈啰技术
https://segmentfault.com/u/hellotech
0
<h2>背景介绍</h2><p>我们小组主要负责四轮场景下的司乘匹配工作,基于开源分布式搜索引擎ElasticSearch实现订单的召回。同时我们使用Flink实时消费kafka消息,将订单数据写入到对应的ES索引中。</p><p><img src="/img/remote/1460000044232401" alt="图片" title="图片"></p><p>想要使用Elasticsearch服务,则要先创建一个可以连接到ES集群的客户端。ES官方提供了很多版本的 Java 客户端,包含但不限于:</p><ul><li>Transport 客户端</li><li>Low Level REST 客户端</li><li>High Level REST 客户端</li><li>Java API 客户端</li></ul><p>早期我们使用的是向SLB域名(SLB是负载均衡服务的缩写)发送请求,在SLB后面有配置对应的ES节点ip,使用SLB来实现负载均衡。但随着流量的增长,在节假日和线上压测时,经常出现SLB带宽超限的问题,影响系统的稳定性。</p><p>于是我们改用ip直连的方式,RestClient客户端本身自带ip节点的负载均衡策略,实现上使用了 Collections.rotate() 方法,感兴趣的可以可以看底层的算法思路。</p><p><img src="/img/remote/1460000044232402" alt="图片" title="图片"></p><p>虽然解决了SLB带宽超限的问题,但现在需手动配置ip列表,比较容易出错,一旦配错一个节点,就会引起线上报错,而且每次扩缩容时都要进行ip列表的变更。</p><p>于是我们开始调研节点的嗅探机制,先从官方文档入手。</p><p>官方文档给出的示例如下:</p><pre><code>RestClient restClient = RestClient.builder(
new HttpHost("localhost", 9200, "http"))
.build();
Sniffer sniffer = Sniffer.builder(restClient)
.setSniffIntervalMillis(60000).build();</code></pre><p>上面代码的含义就是初始化一个嗅探器,每60s更新一次节点列表。</p><p>还可以启用失败嗅探,在每次请求失败之后,节点列表都会立即更新,而不是在下一轮普通的嗅探中更新。这种首先需要创建一个 SniffOnfalureListener,并在创建 RestClient 时设置一个监听器,在每次节点失败时通知该监听器。</p><pre><code>SniffOnFailureListener sniffOnFailureListener =
new SniffOnFailureListener();
RestClient restClient = RestClient.builder(
new HttpHost("localhost", 9200))
.setFailureListener(sniffOnFailureListener)
.build();
Sniffer sniffer = Sniffer.builder(restClient)
.setSniffIntervalMillis(60000)
.build();
sniffOnFailureListener.setSniffer(sniffer);</code></pre><p>除了嗅探器之外,还发现了另一个特殊的配置:节点选择器。</p><p>节点选择器可以实现对节点列表的过滤,比如我想过滤出协调节点,只向协调节点发送请求,就可以定义一个NodeSelector来实现。</p><h2>源码剖析</h2><p>下面我们开始剖析Sniffer组件的底层实现,先进入build()方法:</p><pre><code>public Sniffer build() {
if (nodesSniffer == null) {
this.nodesSniffer = new ElasticsearchNodesSniffer(restClient);
}
return new Sniffer(restClient, nodesSniffer, sniffIntervalMillis, sniffAfterFailureDelayMillis);
}</code></pre><p>我们可以看到实例化一个sniffer对象之前,先创建了一个nodeSniffer对象,指向的类型是ElasticsearchNodesSniffer,这个是真正的发送ES节点嗅探请求的类,我们待会再看。</p><p>接着来到Sniffer的构造方法,看一下作了哪些参数的初始化:</p><ul><li>nodesSniffer:发送节点嗅探请求的对象</li><li>restClient:向ES集群发送请求的客户端</li><li>sniffIntervalMillis、sniffAfterFailureDelayMillis:定时嗅探周期、失败后嗅探延迟时间</li><li>scheduler:定时任务线程池</li></ul><pre><code>Sniffer(RestClient restClient, NodesSniffer nodesSniffer, Scheduler scheduler, long sniffInterval, long sniffAfterFailureDelay) {
this.nodesSniffer = nodesSniffer;
this.restClient = restClient;
this.sniffIntervalMillis = sniffInterval;
this.sniffAfterFailureDelayMillis = sniffAfterFailureDelay;
this.scheduler = scheduler;
/*
* The first sniffing round is async, so this constructor returns before nextScheduledTask is assigned to a task.
* The initialized flag is a protection against NPE due to that.
*/
Task task = new Task(sniffIntervalMillis) {
@Override
public void run() {
super.run();
initialized.compareAndSet(false, true);
}
};
/*
* We do not keep track of the returned future as we never intend to cancel the initial sniffing round, we rather
* prevent any other operation from being executed till the sniffer is properly initialized
*/
scheduler.schedule(task, 0L);
}</code></pre><p>除此之外,我们可以看到实例化了一个Task对象,同时提交任务到了线程池中,任务的延迟时间是0,说明立即执行。从注释可以看出这是初始嗅探,即应用刚启动时立即触发一轮嗅探。</p><p>我们看一下Task对象的run()方法是咋样的。Task有一个属性:nextTaskDelay,从名字可以看出是下一次执行的延迟时间,上面我们初始化时传入的参数是sniffIntervalMillis,即下一次任务在经过一个嗅探周期后执行。可以看到在finally代码块中,向线程池提交的新任务,延迟时间设置的也是nextTaskDelay。</p><pre><code>class Task implements Runnable {
final long nextTaskDelay;
final AtomicReference<TaskState> taskState = new AtomicReference<>(TaskState.WAITING);
Task(long nextTaskDelay) {
this.nextTaskDelay = nextTaskDelay;
}
@Override
public void run() {
/*
* Skipped or already started tasks do nothing. In most cases tasks will be cancelled and not run, but we want to protect for
* cases where future#cancel returns true yet the task runs. We want to make sure that such tasks do nothing otherwise they will
* schedule another round at the end and so on, leaving us with multiple parallel sniffing "tracks" whish is undesirable.
*/
if (taskState.compareAndSet(TaskState.WAITING, TaskState.STARTED) == false) {
return;
}
try {
sniff();
} catch (Exception e) {
logger.error("error while sniffing nodes", e);
} finally {
Task task = new Task(sniffIntervalMillis);
Future<?> future = scheduler.schedule(task, nextTaskDelay);
//tasks are run by a single threaded executor, so swapping is safe with a simple volatile variable
ScheduledTask previousTask = nextScheduledTask;
nextScheduledTask = new ScheduledTask(task, future);
...
}
}
}</code></pre><p>具体的嗅探逻辑就在这个sniff()方法里了,我们接着往下看。</p><pre><code>final void sniff() throws IOException {
List<Node> sniffedNodes = nodesSniffer.sniff();
if (logger.isDebugEnabled()) {
logger.debug("sniffed nodes: " + sniffedNodes);
}
if (sniffedNodes.isEmpty()) {
logger.warn("no nodes to set, nodes will be updated at the next sniffing round");
} else {
restClient.setNodes(sniffedNodes);
}
}</code></pre><p>可以看到调用了nodesSniffer对象的sniff()方法,所以说这个对象才是真正用于发送ES节点嗅探请求的,只好继续看它的sniff()是怎么实现的啦。</p><p>我们发现它使用restClient向ES集群发了一次请求!</p><pre><code>@Override
public List<Node> sniff() throws IOException {
Response response = restClient.performRequest(request);
return readHosts(response.getEntity(), scheme, jsonFactory);
}</code></pre><p>这个请求具体是啥呢,我们在构造函数里找一找:</p><pre><code>public ElasticsearchNodesSniffer(RestClient restClient, long sniffRequestTimeoutMillis, Scheme scheme) {
...
this.request = new Request("GET", "/_nodes/http");
...
}</code></pre><p>可以看到构造了一个Request对象,向集群发送GET请求,请求的url是/_nodes/http。</p><p>请求完成之后,需要对集群的返回结果作解析,readHosts方法主要是作一些json解析工作,我们就不细看了。最终返回一个包含集群中全部节点的Node对象列表。</p><pre><code>static List<Node> readHosts(HttpEntity entity, Scheme scheme, JsonFactory jsonFactory) throws IOException {
try (InputStream inputStream = entity.getContent()) {
JsonParser parser = jsonFactory.createParser(inputStream);
...
return nodes;
}
}</code></pre><p>拿到节点列表之后,当然是设置到restClient实例的属性当中去,所以回到Sniffer.sniff()方法,可以看到最后确实设置到了restClient当中。</p><pre><code>final void sniff() throws IOException {
List<Node> sniffedNodes = nodesSniffer.sniff();
if (logger.isDebugEnabled()) {
logger.debug("sniffed nodes: " + sniffedNodes);
}
if (sniffedNodes.isEmpty()) {
logger.warn("no nodes to set, nodes will be updated at the next sniffing round");
} else {
restClient.setNodes(sniffedNodes);
}
}</code></pre><p>好了,到这我们基本就把Sniffer组件的核心逻辑看完了,可以发现就是每次嗅探后同时向线程池提交新的嗅探任务来实现的,任务的执行时间是设置的嗅探周期。</p><p>当然,我们前面还讲到了可以设置失败嗅探,那这块又是怎么实现的呢?再看一下是怎么设置的:</p><pre><code>SniffOnFailureListener sniffOnFailureListener =
new SniffOnFailureListener();
RestClient restClient = RestClient.builder(
new HttpHost("localhost", 9200))
.setFailureListener(sniffOnFailureListener)
.build();
Sniffer sniffer = Sniffer.builder(restClient)
.setSniffIntervalMillis(60000)
.build();
sniffOnFailureListener.setSniffer(sniffer);</code></pre><p>编码经验丰富的大佬应该看出采用的是监听器模式。监听器模式的本质就是观察者模式,先将回调函数注册到被观察对象,当被观察对象发生变化时,通过回调函数告知观察者/监听者。</p><p>我们看performRequest方法,所有向集群发送的同步请求(比如一次查询请求)最终都会调用这个方法。</p><pre><code>private Response performRequest(final NodeTuple<Iterator<Node>> nodeTuple,
final InternalRequest request,
Exception previousException) throws IOException {
RequestContext context = request.createContextForNextAttempt(nodeTuple.nodes.next(), nodeTuple.authCache);
HttpResponse httpResponse;
try {
httpResponse = client.execute(context.requestProducer, context.asyncResponseConsumer, context.context, null).get();
} catch(Exception e) {
RequestLogger.logFailedRequest(logger, request.httpRequest, context.node, e);
onFailure(context.node);
...
}
...
}</code></pre><p>可以看到在请求执行异常时(比如查询超时、网络异常),会执行到onFailure方法,在这里就会调用监听器中的方法。</p><pre><code>private void onFailure(Node node) {
...
failureListener.onFailure(node);
}</code></pre><p>我们看一下SniffOnFailureListener这个监听器的实现:</p><pre><code>public class SniffOnFailureListener extends RestClient.FailureListener {
private volatile Sniffer sniffer;
@Override
public void onFailure(Node node) {
if (sniffer == null) {
throw new IllegalStateException("sniffer was not set, unable to sniff on failure");
}
sniffer.sniffOnFailure();
}
}</code></pre><pre><code>public void sniffOnFailure() {
if (this.initialized.get() && this.nextScheduledTask.skip()) {
this.scheduler.schedule(new Task(this.sniffAfterFailureDelayMillis), 0L);
}
}</code></pre><p>可以看到确实是会立即触发一次嗅探。启用失败后嗅探的好处就是如果集群中有节点下线能够及时将其从节点列表中移除,而不用等到下一个嗅探周期。</p><h2>总结</h2><p>我们ES客户端的节点配置初始化从SLB域名切换到静态ip列表,目的是移除对SLB的依赖,但由于人工配置ip列表容易出错,使用ElasticSearch节点嗅探机制,减少人工操作,提高扩缩容效率。</p><p>(本文作者:方选豪)</p><p><img width="732" height="246" src="https://segmentfault.com/img/bVc86qd" alt="图片" title="图片"></p>
AI平台如何赋能后端研发
https://segmentfault.com/a/1190000044203893
2023-09-11T12:11:20+08:00
2023-09-11T12:11:20+08:00
哈啰技术
https://segmentfault.com/u/hellotech
0
<p>随着人工智能的发展和技术进步,越来越多的企业开始使用人工智能技术做效率的提升和业务效果的提升,降低企业成本,增强企业竞争力。本文将基于哈啰AI平台的能力,以接入普惠工单系统自动转派为例,讲述如何通过算法能力赋能后端研发提效。</p><h2>建模流程</h2><p>整个模型建模流程主要需要四大步:特征处理、模型训练、模型评估、模型部署。并且模型还需要进行不断的迭代才能保证模型效果。<br><img src="/img/remote/1460000044203895" alt="图片" title="图片"></p><ul><li>特征处理<br>包括特征存储、特征选择、特征清洗、特征加工等</li><li>模型训练<br>包括模型基础环境、特征预处理、超参调优、训练加速等</li><li>模型评估<br>包括超参评估、效果评估、耗时评估等</li><li>模型部署<br>包括TF模型、Pytorch模型、PMML模型、GPU模型等</li></ul><h2>AI平台方案</h2><p><img src="/img/remote/1460000044203896" alt="图片" title="图片"></p><h3>特征处理</h3><p>包括特征存储、特征选择、特征清洗、特征加工等。</p><p>下面以特征存储和特征加工为例,举例如何通过页面,最简化支持特征的存储和加工。</p><h4>特征存储</h4><p>特征平台目前支持在线特征的存储,简单来说用户可以通过在AI平台点通过页面配置化的方式,将hive表数据或者kafka、rocketmq数据,同步到hbase、hedis、redis等在线存储中,也支持将数据存储到rocksdb进行本地化存储。如下图所示:</p><p><img src="/img/remote/1460000044203897" alt="图片" title="图片"></p><p><img src="/img/remote/1460000044203898" alt="图片" title="图片"></p><h4>特征加工</h4><p>特征清洗的例子如下,AI平台先将部分特征工程逻辑算子化,如下图Normalizaiton为归一化算子。</p><p>落为算子后,后续自动化建模就可以直接在特征工程模块选择Normalization算子,这样在模型进行训练之前就会对特征进行归一化处理。</p><p><img src="/img/remote/1460000044203899" alt="图片" title="图片"></p><p><img src="/img/remote/1460000044203900" alt="图片" title="图片"></p><h3>模型训练</h3><p>包括特征预处理、镜像管理、工作空间作业建模、超参调优、分布式训练、自动化建模。</p><p>下文以自动化建模为例,讲述如何通过配置化的方式接入。</p><h4>自动化建模</h4><p>在自动化建模模块,先通过模型种类和模型分类,选择合适的模型类别。</p><p>选择AutoML模式,则会自动帮用户选择模型。下图选择了专家模式,则需要用户自己选择深度学习的多分类模型。</p><p>如下图所示,选择了IntentRecognitionNNIAndRay模型,该模型是用ALBERT写的意图识别模型。选择意图识别模型后,可以选择特征的初筛、数据处理、特征选择逻辑,在自动化训练前进行特征的处理,下图展示了特征处理逻辑,可以对特征进行特征初筛、特征加工、特征选择。</p><p><img src="/img/remote/1460000044203901" alt="图片" title="图片"></p><p><img src="/img/remote/1460000044203902" alt="图片" title="图片"></p><p><img src="/img/remote/1460000044203903" alt="图片" title="图片"></p><h3>模型评估</h3><p>超参搜索可视化、模型多版本AB、模型自动评估、模型手动评估、模型性能诊断、模型自定义评估逻辑等。</p><p>下文展示了AI平台实现的超参搜索可视化和模型评估。</p><h4>超参搜索可视化</h4><p>AI平台模型训练接入了NNI框架,将训练时的超参搜索可视化,这样用户在训练完成后可以通过超参搜索的分布判断模型是否存在问题,或者可以优化超参分布,从而使模型的效果更好。如下图所示:</p><p><img src="/img/remote/1460000044203905" alt="图片" title="图片"></p><h4>模型自动评估</h4><p>通过自动化建模后,模型选择手动评估或者自动评估。以自动保存为例,可以在评估时选择评估的指标值,以及评估规则。后续模型训练后即可通过评估结果进行模型自动替换。</p><p><img src="/img/remote/1460000044203906" alt="图片" title="图片"></p><h3>模型部署</h3><p>支持TF模型、Pytorch模型、PMML模型、GPU模型。AI平台模型支持TensorFlow模型、PMML模型、Pytorch模型、GPU模型,还支持Faas化部署,如下所示,可以选择不同的类型,将模型文件上传,AI平台会将模型部署到对应的应用集群中。</p><p><img src="/img/remote/1460000044203907" alt="图片" title="图片"></p><h3>意图识别算法</h3><p>简单来说就是通过算法,识别用户的需求,即明白用户想做什么。</p><p>意图识别是人机对话构成的关键,而用户意图需要不断的对话才能真正的理解。</p><h4>意图识别场景</h4><p>意图识别的应用场景对话系统、搜索引擎等。意图识别例子搜索引擎很好理解,举例来说,你从谷歌输入了“八角笼”,意图识别会根据当前的热度和你的习惯进行识别,比如你是一个电影迷,会识别到你是想了解八角笼中的电影。</p><p>对话系统:举例来说,你输入小米,系统会至少有两种语义识别出来,一种是粮食小米,一种是小米品牌,而当你继续输入雷军,系统就了解你需要知道的是小米品牌。</p><h3>BERT</h3><p>BERT是自编码语言模型,通过Masked LM(随机屏蔽) 和 Next Sentence Prediction(下一句预测)两个任务来训练该模型。</p><h4>Masked LM</h4><p>该思想来源于完形填空,简单来说,在一句话中,随机抹去这句话的一个或者多个词汇,要求根据剩余的词汇进行预测抹去的词分别是什么。如下图所示:bert分别隐去了一句话中的两个字,然后让模型进行“完形填空”自动补全。</p><p><img src="/img/remote/1460000044203908" alt="图片" title="图片"></p><p>好处:这样做迫使模型更多依赖上下文信息去预测,赋予了模型一定的纠错能力。</p><p>缺点:每批次只有15%的标记被预测,模型需要更多的预训练步骤来收敛。</p><h4>Next Sentence Prediction</h4><p>判断第二句话是否在第一句话的后面。该任务与Masked LM任务相结合,让模型能够准确的理解和刻画语句或者文章的语义信息。</p><p>如下图所示:输入两句话,让模型能够判断是否为连续的句子,一个是正确的连续句子,一个是错误的连续句子。</p><p><img src="/img/remote/1460000044203909" alt="图片" title="图片"></p><h3>ALBERT</h3><p>BERT的改进版本很多,而ALBERT全称为A Lite BERT,是轻量化的版本。ALBERT采用了两种减少模型参数的方法,让其比BERT所需内存空间更小,并且提升训练速度。</p><p>原来的BERT由于模型参数过多,并且模型太大,导致少量数据容易过拟合,ALBERT则可以支持更少的数据,达到更好的效果。</p><h2>普惠场景接入</h2><p>普惠接入AI平台的场景是一个客服工单自动转派场景。该场景基于意图识别的ALBERT算法(由于普惠工单系统训练数据较少,因此ALBERT是很合适的模型),将客服客诉问题进行打标和打分,对符合分数要求的场景进行自动的转派和自动问题订正。</p><h3>原流程</h3><h4>原流程步骤</h4><p>原流程如下图左边所示:用户在哈啰app提交客诉->客服人员判断具体业务线->客服人员转派工单到打车业务线->打车后端值班同学转派工单->开发解决问题->客服人员确认。</p><p>该流程中,很重要的2步是打车值班同学转派工单和开发解决问题。</p><h4>工单转派遇到的问题</h4><p>以打车后端值班同学转派工单流程为例:由于日常客诉较多,因此每周会统一安排的一个开发进行值班,而开发人员每个人做的方向是不一样的,且水平有高有低,因此很多工单会转派错误,或者需要咨询对应的同学后进行转发,不仅准确率不高,且效率较低。</p><h4>工单处理遇到的问题</h4><p>开发解决问题也有和转派类似问题,由于开发人员的水平不一致和做的方向不一致,因此解决问题的人十分分散,且解决周期也各不相同。因此最好的解决办法是,工单能通过系统自动转派对应的人,且问题可以通过系统自动订正,这样值班人员不需要对该类问题进行工单转派,也不需要单独有人进行问题处理,系统能够完全进行自动的处理。</p><h3>新流程</h3><p>新流程如下图右边所示,主要变化为将打车值班同学转派工单和开发解决问题两步进行了自动化处理。</p><p><img src="/img/remote/1460000044203910" alt="图片" title="图片"></p><h3>自动转派和自动处理方案</h3><p>原方案打车侧的原方案为关键词匹配,即根据客服的描述,通过关键字进行匹配,该方案的准确率很低(准确率不足10%)。</p><h4>算法方案</h4><p><strong>自动转派逻辑</strong><br>业务开发对不同的描述进行打标,每个标签对应不同的开发。举例如下:左边客服系统的描述,右侧是给该描述打的标签。而每一个标签后面都会对应相应的处理同学。</p><p><img src="/img/remote/1460000044203911" alt="图片" title="图片"></p><p>算法根据客诉描述,推理出该描述对应的标签和概率,然后对系统留下概率超过一定值的分类(线上为0.7),进行转派。如下图所示:</p><p><img src="/img/remote/1460000044203912" alt="图片" title="图片"></p><p><strong>自动处理逻辑</strong><br>根据标签的结果,对部分标签(配置化)进行调用接口自动化处理。</p><h3>接入流程</h3><h4>新场景</h4><p>新场景接入流程如下所示(新沉淀算子流程):<br>需求新增:后端研发根据业务场景给AI平台提需求<br>需求评估:AI平台会根据用户需求评估可行性,并判断是否可以复用已有算子(模型)<br>模型研发:AI平台进行模型研发,如模型过于复杂,则AI平台会协调算法进行模型研发<br>模型评估:AI平台进行效果评估,如模型效果评估通过,会沉淀为算子,如不通过,会进行模型的分析和修正<br>算子沉淀:AI平台同学根据模型沉淀为算子<br>文档沉淀:AI平台会将该算子的使用方案和接入沉淀文档<br>用户接入:后端研发根据接入文档进行模型的在线推理的接入</p><p><img src="/img/remote/1460000044203913" alt="图片" title="图片"></p><h3>复用算子场景</h3><p>新场景接入流程如下所示(复用算子流程):<br>需求新增:后端研发根据业务场景给AI平台提需求<br>需求评估:AI平台会根据用户需求评估可行性,并判断是否可以复用已有算子(模型)<br>用户接入:后端研发根据接入文档进行模型的在线推理的接入</p><p><img src="/img/remote/1460000044203914" alt="图片" title="图片"></p><h2>总结</h2><p>整体来说,AI平台目前通过配置化的方式支持特征的存储,并通过算子库的方式,将特征的处理逻辑和算法算子化,这样用户完全无需算法的知识就能进行自主接入。并通过自动化工作流,实现了模型的自动训练、自动评估和自动替换。而有算法经验的同学,可以根据超参调优和超参调优可视化进行模型的调试和训练。目前基于EasyML2.0接入的场景上线大部分场景效果超过预期。</p><p>(本文作者:柳健强)</p><p><img width="732" height="246" src="https://segmentfault.com/img/bVc86qd" alt="图片" title="图片"></p>
JIT逆优化导致ES集群CPU异常的问题分析
https://segmentfault.com/a/1190000044184570
2023-09-05T17:15:59+08:00
2023-09-05T17:15:59+08:00
哈啰技术
https://segmentfault.com/u/hellotech
0
<h2>背景</h2><p><img src="/img/remote/1460000044184572" alt="图片" title="图片"></p><p>在一次全链路压测过程中,顺风车匹配ES集群出现了个别节点CPU几乎被打满的情况。第二轮压测,我们关闭了最近上线的H3召回匹配升级AB实验,在同样压力下集群cpu运行平稳,保持在35%左右,开启AB实验后之前异常节点cpu又急速增加,初步定位到节点异常应该和H3召回升级实验相关。</p><h2>问题复现</h2><p>由于压测是在凌晨进行,当时并没有对异常节点的堆栈信息进行详细的拉取和分析。所以要找到问题的原因我们首先需要复现问题,刚好上半年我们完成线上es双集群的项目,在业务低峰期可以把线上流量切换到其中一个es集群中,另外一个集群和预发环境的应用进行连接,通过在预发环境发起请求进行模拟压测,很快便复现了问题,es集群cpu从10%急速增长到100%,详见下图。</p><p><img src="/img/remote/1460000044184573" alt="图片" title="图片"></p><p>在整个过程中网络、磁盘、内存相关指标均未出现大的波动,主要消耗是在system load(90%)。</p><p><img src="/img/remote/1460000044184574" alt="图片" title="图片"></p><p>然后,使用Arthas工具生成异常节点的火焰图和热点线程占用,进行分析。</p><p><img src="/img/remote/1460000044184575" alt="图片" title="图片"></p><p>通过以上火焰图数据分析,我们发现在程序的主要CPU浪费在Deoptimization::uncommon_trap里。</p><p>然后开始从Deoptimization::uncommon_trap 入手查阅一些相关的资料,发现这个原来是jit的一种逆优化策略,我们先来看下什么是jit和逆优化。</p><h2>背景知识:JIT优化和逆优化</h2><p>为了提高热点代码(Hot Spot Code)的执行效率,在运行时,虚拟机将会把这些代码编译成与本地平台相关的机器码,并进行各种层次的优化,完成这个任务的编译器称为即时编译器(JIT)。</p><p><img src="/img/remote/1460000044184576" alt="图片" title="图片"></p><p>“热点代码”两类:<br>被多次调用的方法。<br>被多次执行的循环体 – 尽管编译动作是由循环体所触发的,但编译器依然会以整个方法(而不是单独的循环体)作为编译对象。</p><p>Deoptimization::uncommon_trap是一个在JIT编译器中用于处理不常见陷阱的机制。当JIT编译器对一个方法进行编译时,会根据当前的执行环境和代码的特性进行优化,生成相应的本地机器代码。但是,由于一些特殊情况或者代码变动,之前优化的代码可能不再适用。</p><p>当发生这种情况时,JIT编译器会触发Deoptimization::uncommon_trap机制。这个机制会将当前的执行状态标记为不常见,然后将控制流返回到解释器或者其他备用代码路径,以便重新执行相应的代码。在重新执行的过程中,JIT编译器会重新生成适用于新情况的本地机器代码。</p><p>Deoptimization::uncommon_trap机制的目的是为了保证编译后的代码的正确性和可靠性。当代码执行环境或者代码本身发生变化时,通过触发不常见陷阱,可以及时修复和重新优化代码,以保证程序的正确性和性能。</p><p>如:</p><pre><code>static void test(Object input) {
if (input == null) {
return;
}
// do something
}</code></pre><p>如果input一直不为空,执行1W次时,那么上述代码将优化为:</p><pre><code>static void test(Object input) {
// do something
}</code></pre><p>但是如果之后出现input为空,那么将会触发Uncommon Trap,通过逆优化(Deoptimization)退回到解释状态继续执行。</p><p>如果程序一直在执行Deoptimization::uncommon_trap,可能有以下几个可能的原因:</p><ul><li>频繁的代码变动:如果程序中频繁地修改代码,特别是对于经过优化的热点代码,会导致JIT编译器反复触发不常见陷阱来重新优化代码。这可能是因为代码变动导致了之前的优化假设不再成立,需要重新优化代码。</li><li>动态类型变化:如果程序中存在频繁的动态类型变化,例如方法的参数类型经常变化,JIT编译器可能会触发不常见陷阱来处理类型不匹配的情况。这种情况下,可以考虑使用类型稳定的代码模式或进行类型检查来减少不常见陷阱的发生。</li><li>程序本身的特性:某些程序的特性可能会导致频繁的不常见陷阱,例如大量的动态代码生成、复杂的多态调用等。在这种情况下,可能需要重新设计程序结构或使用其他优化技术来减少不常见陷阱的发生。</li></ul><h2>定位问题&解决方案</h2><p>了解了相关jit和逆优化的知识后,开始结合节点异常期间的热点线程进行具体代码的跟进分析。</p><p><img src="/img/remote/1460000044184577" alt="图片" title="图片"></p><p>经过进一步分析,我们发现在对三个实验组进行了处理逻辑区分的时候使用switch的方式进行判断,java虚拟机对这块代码设别为热点代码,当方法被执行的次数+方法体内总循环的执行次数 > 阈值,会触发JIT编译成本地代码进行了优化。如果当时按照其中一个实验组进行编译的优化,当其他实验组开流量时这种优化策略便不成立,这时候就出现了逆优化(Deoptimization)。</p><p>解决方案:把原来使用switch方式进行实验组逻辑判断的代码改造成使用map函数式编程的方式。通过优化可以减少程序中的条件分支,避免逆优化问题的出现。</p><pre><code>HashMap<String, Function<Map<String, ScriptDocValues<?>>, Boolean>> methodMap = new HashMap<>();
methodMap.put("exp1", this::executeForExp1);
methodMap.put("exp2", this::executeForExp2);
methodMap.put("exp3", this::executeForExp3);
executeFunction = methodMap.get(h3Version);</code></pre><p><img width="723" height="393" src="/img/bVc9yAk" alt="image.png" title="image.png"></p><p>在优化完成后在相同压力下进行了压测,发现CPU异常问题得到了解决。</p><p>(本文作者:郑崇祥)</p><p><img width="732" height="246" src="https://segmentfault.com/img/bVc86qd" alt="图片" title="图片"></p>
AI平台AutoML在哈啰的探索与实践
https://segmentfault.com/a/1190000044128539
2023-08-18T16:22:18+08:00
2023-08-18T16:22:18+08:00
哈啰技术
https://segmentfault.com/u/hellotech
0
<h2>一站式AI平台架构</h2><h3>产品架构</h3><p><img src="/img/remote/1460000044128541" alt="图片" title="图片"></p><p>算法模型的研发具有很强的范式,首先是特征数据加工,选取一些数据作为特征。拿到特征之后,选择一个模型并进行相关的训练。第三步是把训练好的模型部署到模型平台上面。最后,决策平台会做业务流程的编排。</p><p>如图是整个平台的产品架构图。上面是各类应用场景,包括智能调度、营销&增长、司乘匹配、基础算法等。平台接入层提供内部SOA协议的接入,上面有各种各样的服务,会对接我们的平台。我们的平台分为离/近线系统和近/在线系统,其中离/近线系统包括特征平台和训练平台,近/在线系统包括模型平台和决策平台。</p><h3>技术架构</h3><p><img src="/img/remote/1460000044128542" alt="图片" title="图片"></p><p>如图是AI平台的技术架构图。从上往下,决策平台层是在线服务的入口,除了算法在上面做一些流程编排,一个重要的职责是承担了在线业务流量的稳定性。为了加速模型推理的功能,我们把模型直接跟特征绑定,拉取到本地。在这个过程中,如何把海量数据,大量的高维特征加载到本地机器上运行,让本地的模型直接读取,是有挑战性的地方。二是在线部分,分布式动态扩缩容、高可用、限流熔断,也是我们的核心能力之一。三是模型平台,要面对各种各样的算法框架所开发出的模型,如tensorflow模型、pytorch模型等,这些模型还会经过一些常用的模型压缩算法,变成优化好的模型。我们把这些模型加载起来,有Tensorflow集群、GPU集群、Python集群等。</p><p>接下来,底层的特征平台主要应用的是大数据技术。上面可以部署各种定时任务,这些任务是通过spark的脚本分发给数据平台,申请计算资源,最后进行算法的推理和计算。同时,我们对hive、数据湖都需要有一定的了解。</p><p>最后是云原生相关技术的应用。训练平台用的是云原生的docker直接加载jupyter notebook镜像,把这些资源释放给算法同学使用,获得能效的提升。</p><h3>发展进程</h3><p><img src="/img/remote/1460000044128543" alt="图片" title="图片"></p><p>我们在2021年做了平台化,2022上半年进行稳定性治理和性能优化,下半年在自动化和实时化上发力。自动化是为了提升效率,降低门槛;实时化是为了提升算法效果和用户的体验。</p><h2>自动化训练的实践</h2><h3>为什么需要自动化训练</h3><p><img src="/img/remote/1460000044128544" alt="图片" title="图片"></p><p>机器学习有着固定的研发流程,问题抽象、模型选择、超参调优等比较依赖算法工程师经验。</p><h3>业内情况和发展</h3><p><img src="/img/remote/1460000044128545" alt="图片" title="图片"></p><p>AutoML最早由Google在2018年初提出,主要分为Auto FE(自动特征工程)、HPO(超参优化)、NAS(神经网络架构搜索)。</p><p><img src="/img/remote/1460000044128546" alt="图片" title="图片"></p><p>华为、阿里、百度、美团等国内大厂纷纷跟进,应用于实际生产。</p><h3>HPO效果测试集</h3><p><img src="/img/remote/1460000044128548" alt="图片" title="图片"></p><p>在上线之前,拿了内部真实的场景,对AutoML技术做了一些测评。通过AutoML里HPO的算法,去优化我们的超参,优化后的效果有了小幅的提升。</p><h3>技术方案</h3><p><img src="/img/remote/1460000044128549" alt="图片" title="图片"></p><p>基于开源项目Ray Tune与NNI提供的基础能力,通过Python SDK供算法代码使用,初期算法通过代码模板选取训练代码。</p><h3>产品方案</h3><h4>编程式建模</h4><p><img src="/img/remote/1460000044128550" alt="图片" title="图片"></p><h4>交互式建模</h4><p><img src="/img/remote/1460000044128551" alt="图片" title="图片"></p><h4>自动化流程</h4><p><img src="/img/remote/1460000044128552" alt="图片" title="图片"></p><h3>赋能场景</h3><p><img src="/img/remote/1460000044128553" alt="图片" title="图片"></p><p>AutoML在哈啰广告CTR预测场景下上线,如图是哈啰APP首页腰封的营销广告。为了提升广告的点击率,我们进行了优化,使用的是DeepFM模型,在这个模型下以前没有用到超参搜索。类似的场景还有很多,实际效果基本都得到了提升。</p><h2>未来展望和规划</h2><p><img src="/img/remote/1460000044128554" alt="图片" title="图片"></p><p>一是数据和特征决定了机器学习的上限,模型和算法只是逼近这个上限而已,因此特征的生产和选择很依赖经验,有一定的提升空间。二是在模型自动选择上,算法可以代替人工经验,通过算法对比不同模型的效果,最终选择最优解。后面我们也有开源计划,目前在规划中。</p><p><img src="/img/remote/1460000044128555" alt="图片" title="图片"></p><p>我们的愿景是人人都是算法工程师。算法代码有很强的范式,模型的开发和使用也趋近于稳定并积累了大量经验,调参模型开发等机械的工作更多的被机器替代,我们应该更专注于业务场景的分析、问题的抽象与定义、新技术(AIGC)工程化的实践等。</p><p>(本文作者:任天兵)</p><p><img width="732" height="246" src="https://segmentfault.com/img/bVc86qd" alt="图片" title="图片"></p>
端智能在哈啰的落地实践
https://segmentfault.com/a/1190000044088302
2023-08-07T15:59:35+08:00
2023-08-07T15:59:35+08:00
哈啰技术
https://segmentfault.com/u/hellotech
0
<h2>端智能及其解决的问题</h2><h3>边缘计算</h3><p>端智能和边缘智能是比较类似的概念,而边缘智能会依赖另一个更广泛的技术,即边缘计算。边缘计算指的是在网络边缘结点来处理、分析数据。边缘结点指的就是在数据产生源头和云中心之间任一具有计算资源和网络资源的结点。比如手机就可以是人与云中心之间的边缘节点,网关是智能家居和云中心之间的边缘结点。边缘计算把更多的计算进程放到边缘节点,云端运行较少的进程。</p><p>举个章鱼的例子,章鱼的大脑类似于云计算,具有较强的计算能力,能够解决很多复杂的问题。八个小爪子就是边缘计算,每个爪子就是一个小型的机房,一些简单的计算任务就不需要传输到大脑进行计算。</p><h3>端智能</h3><p><img src="/img/remote/1460000044088304" alt="图片" title="图片"></p><p>端智能,顾名思义就是让端具备思考决策的能力。如图是一般的决策链路,我们的手机端要展示的内容其实是要去请求服务端,由服务端给我们决策的结果,再去展示到客户端,相当于是去请求结果这样的过程。而端智能可以在端上去做一些推理和决策,并可以将记忆学习或深度学习的算法部署在上面,这样在有了数据和算法之后,可以做一些推理的过程。</p><p><img src="/img/remote/1460000044088305" alt="图片" title="图片"></p><p>传统的推荐系统会存在一些问题,一般的分页请求会导致策略调整的不及时,云端获取用户行为的延迟在秒级甚至分钟级,很多用户细粒度的行为无法获取。有了端智能后,可以在端侧实时感知、计算、决策、干预。</p><p><img src="/img/remote/1460000044088306" alt="图片" title="图片"></p><p>端上会有大量的用户行为特征以及一些特有的数据,这些数据不需要传到云端。有了这些数据后,可以很快做出一些推理决策,如果这时传到云端做推理决策,延迟就会很大。</p><h3>端智能VS云智能</h3><p>相比云智能来说,端智能有以下一些优势:</p><ul><li>低延时: 模型推理在端上进行,无需与云端进行网络请求,降低了响应时间。低延时对于一些实时性要求高的场景极为重要;</li><li>安全性: 数据无需传往云端,可以更好地保护用户隐私数据;</li><li>定制化: 根据用户习惯进行本地训练,步步优化,可以更好的实现"千人千面";</li><li>节省资源: 在端侧处理,利用端侧算力和存储空间,可以节省大量的云端计算和存储资源。同时,</li></ul><p>端智能也存在一些挑战:</p><ul><li>设备的碎片化:端侧设备严重碎片化,复杂多样的操作系统和系统版本。如何保证模型适配各种设备且充分利用加速;</li><li>模型与引擎大小:模型太大会影响加载速度,运行时也会占用巨量内存。推理引擎需要集成到app中,app太大也会占据大量存储;</li><li>内存占用: 运行时会占用大量内存,会影响用户体验。</li></ul><p>需要注意的是,端智能与云智能本身就不是割裂的技术体系,不是非此即彼,而是相辅相成、互为补充。</p><h3>端智能应用场景</h3><p>体验类:</p><ul><li>AR/VR,音视频超分,背景分割,人脸美颜</li></ul><p>安全场景:</p><ul><li>支付宝判断手机丢失:根据端侧传感器、手势、行为序列搜索</li></ul><p>推荐营销:</p><ul><li>端上智能在快手上下滑推荐</li><li>淘宝信息流刷新重排</li><li>美团重排序在点评主搜和美食频道列表页</li></ul><h2>端智能在哈啰的落地方案</h2><h3>技术储备</h3><ul><li>已建设云端AI平台能力:训练平台、模型平台、特征平台、决策平台</li><li>客户端和算法均在对应领域有一定的积累</li><li>开源的推理引擎框架</li></ul><h3>端智能架构</h3><p><img src="/img/remote/1460000044088307" alt="图片" title="图片"></p><p>在有端智能之前,如左边所示,我们的AI平台会提供一些模型,让搜推引擎去调用,最终产生排序的结果。有客户端的加入之后,当用户触发了某种行为之后,可以对它做重排序。整体的流程要有端上重排的触发,触发后需要做重排的推理过程。如下面的链路所示,首先要拿到对应的一些数据,之后做特征的处理,以及模型的下拉,推理引擎的用的是MNN,这样整个重排服务会产生模型推理的结果,再把这个结果返回给客户端。</p><p>过程中也遇到了一些挑战,首先模型版本管理问题,模型自动更新如何保证线上一致性,这里设计了模型管理模块。其次是模型文件的分发,我们什么时候把模型文件下载到APP,以何种方式,每一种方式成本也不一样,这里通过调研设计了模型下拉模块。第三,我们要把推理引擎集成到APP里,包的大小怎么控制。第四,模型文件要想在端上顺利运行需要有一定的环境,由于用的是MNN推理引擎框架,它的执行环境是C++。最后还需要把我们的模型文件转换成MNN,会遇到算子是否支持的问题。</p><p><img src="/img/remote/1460000044088308" alt="图片" title="图片"></p><p>对应于挑战,我们做了一些模块,包括特征管理模块、模型管理模块,以及触发管理模块。我们的技术栈主要用到C++和Java。</p><h3>端智能数据流</h3><p><img src="/img/remote/1460000044088309" alt="图片" title="图片"></p><p>我们要在手机端上做触发,需要拿到端侧的一些行为数据,以及从云端拉取一些数据,同时需要一些业务数据,所有的数据融合之后进入到算法SDK做特征预处理,随后进入到推理引擎做端侧的模型推理,推理结果再加入算法SDK做业务规则处理,这样就会把整个结果展示给业务移动端。算法模型和特征对于算法模型和特征,由于云端的特征比较多,怎么保证重排和精排模型达到的效果是相互补充的,这时就需要去采集比较多的端侧特征,需要细粒度且变化快。这里的端侧特征有轮播次数、停留时长、滑动类型、点击不感兴趣等。</p><p><img src="/img/remote/1460000044088310" alt="图片" title="图片"></p><p>模型会对这些端侧特征进行学习,与精排模型不同的是,除了用户的正反馈,还会加入用户产生的负反馈行为,如曝光的没有点击,或进入到详情页后马上退出。这样的重排模型一般体积不会太大,能达到较好的实时性效果。与云上推荐相比,端上推荐的用户行为延迟和系统全链路反应到用户的时间得到了明显的改善。</p><h3>应用场景</h3><p><img src="/img/remote/1460000044088311" alt="图片" title="图片"></p><p>端智能建设过程中第一个上线的是首页banner的场景。之前服务端会下发6帧轮播,但是因为端智能的加入会有策略的改变,服务端下发20帧,实时挑6帧展示。触发逻辑是轮播完成用户没有点击和用户点击进入详情页,分别表示用户不感兴趣和感兴趣。触发后会在端侧基于用户实时行为特征对卡片进行打分重排序,不感兴趣的进行降权。</p><p>同时有一些规划中的场景,如首页的联合推荐。目前首页接入搜推算法的有底纹词、宫格、banner和二屏,如果每个系统独立没有任何交互,会造成流量的浪费。我们需要去做全域的数据互通,联合决策。</p><p>规划中的场景还有弹窗时机实施决策,弹窗时机是根据用户长期行为偏好、实时行为序列及设备实时性能状态,去判断用户的意图。综合考虑用户的疲劳度和意愿度,决定是否弹窗和弹窗的内容。</p><h2>未来展望</h2><p>端智能和云智能是相辅相成、相互补充的过程,可以在很多场景全面使用。接下来有三个展望,一是如何更多的应用到端智能;二是如何去降低接入门槛,让算法模型更简单的部署,让开发同学更方便的去使用;三是如何做到从端智能到端云协同智能,如何实现端云协同算法突破。</p><p>(本文作者:侯亚伟)</p><p><img width="732" height="246" src="https://segmentfault.com/img/bVc86qd" alt="图片" title="图片"></p>
哈啰开源Dora:深度解析Taro多业务线小程序协作构建工具与前端协作流
https://segmentfault.com/a/1190000044076304
2023-08-03T11:22:56+08:00
2023-08-03T11:22:56+08:00
哈啰技术
https://segmentfault.com/u/hellotech
0
<h2>什么是dora</h2><p>dora是一个哈啰的开源的taro小程序微前端集成框架,具有把多页业务拆分并集成编译与通讯的能力,解耦了业务与业务,降低了总体的复杂度与多业务线合作难度,有轻量化扩展性强等特点。</p><p>项目地址:<a href="https://link.segmentfault.com/?enc=ZsrdFTbmbdJmvmOZzxhcuQ%3D%3D.6F4ia6NaqsGKIF40E3nO23FlIteaB3mGhjr2SIYuzaMTfmuMtlX6E6409Dt2UtZ7" rel="nofollow">https://github.com/hellof2e/dora</a> (欢迎star~)</p><h2>为什么要编写dora</h2><p>市面上的多仓库协作比如git submoudle,它的使用比较晦涩偏向基础能力直接暴露,业务线同学理解比较困难,比如lerna适合基础库的维护和发布,dora作为多业务线协作工具使用简单,原理清晰,可扩展性强,业务线同学理解容易,也包含了发布代码必须包含master等检测功能,更偏向业务线的场景,所以dora就这样诞生了。</p><h2>概念定义</h2><p>dora在父应用根目录创建config.json来管理多个子应用, json内容如下:<br>subAppName - 子业务的称呼<br>path - 子仓库在仓库中的位置<br>tag - git hash<br>repository - 仓库地址</p><p><img src="/img/remote/1460000044076306" alt="图片" title="图片"></p><p>dora可以创建在项目不同的位置中。</p><p><img src="/img/remote/1460000044076307" alt="图片" title="图片"></p><h2>基本使用</h2><h3>安装</h3><p><code>$ npm i -g @hellobikefe/dora</code></p><h3>指令</h3><p>使用dora -h查看帮助。</p><pre><code>命令
Options:
-V, --version output the version number
-h, --help display help for command
Commands:
publish 发布子应用代码至父应用
update [options] 更新子应用
help [command] display help for command</code></pre><h3>接入</h3><h4>config.json & config.ts/js</h4><p>配置config.json在父应用与子应用中,子应用包含路由和event,父config记录子应用tag path等。在项目初始化的时候可以手动clone子仓库到想要的目录,随后在子应用根目录执行dora publish。</p><pre><code>//父亲仓库config.json
{
"apps": {
"doraSubappExample": {
"configPath": "./src/doraSubappExample/config.ts",
"path": "./src",
"repository": "git@github.com:gjc9620/dora-subapp-example.git",
"subAppName": "doraSubappExample",
"tag": "1.0.0-release/1.0.0-1689675708545"
}
}
}</code></pre><p>子仓库可以参考<a href="https://link.segmentfault.com/?enc=p8UPKfUJm%2FqFEptHHyevpA%3D%3D.g9WqJXoLBZxmoj%2BCuAKsY84DtjhnDrN3axb0rMFZxHWkTCqFkrHHazff22wZpoz7eAKfwHoSsVakS6x8qf%2F6DtUK%2B7Fs679MQWqAg006VnwUHNX9kSd4jWHqXOWLY7cD23vegiUd%2B52QNkefGVOWHA%3D%3D" rel="nofollow">此配置</a></p><h4>package.json</h4><p>在接入的子仓库的package.json中编写subappname属性。</p><pre><code>{
"version": "1.0.0",
"subAppName": "doraSubappExample"
}</code></pre><h4>babel</h4><p>增加babel插件 执行npm i babel-plugin-macros@3.1.0,随后在config/index中添加如下代码。</p><pre><code>
const macros = (chain) => chain.merge({
module : {
rule : {
myloader : {
test : /(node_modules|src).*\.(ts|tsx|js|jsx)$/,
use : [{
loader : 'babel-loader',
options : {
plugins : [
'macros',
],
},
}],
},
},
},
});
//增加
webpackChain(chain) {
macros(chain)
},</code></pre><p>到这里配置就完成了。</p><p>具体可以参考这2个仓库:</p><p><a href="https://link.segmentfault.com/?enc=W0uyABZ9Ic7pslVyO2Ptag%3D%3D.E%2FzLMfq98bSLnYI%2BDDUU4hroFaLS9nJEfzyUOpNospNTnz0eI7Oyp0mo4fMMptfE" rel="nofollow">https://github.com/hellof2e/doraAppExample</a> 父应用demo</p><p><a href="https://link.segmentfault.com/?enc=15z0qyj3eolQDMeOiI%2FiWA%3D%3D.HV75hoyyEW27%2FSb%2Be3BayPvhSOaXzBtnH3NoGmsDxe2G9jIPyD4fJ9Hwi%2BdjOifm" rel="nofollow">https://github.com/hellof2e/doraSubappExample</a> 子应用demo</p><h3>版本控制</h3><h4>dora update</h4><p>dora update把所有subapp的版本切换为父应用中的版本。</p><h4>dora publish</h4><p>dora publish在子应用根目录执行dora publish会把当前目录publish到父仓库中去,请确定你拥有父仓库与子仓库的push权限。</p><h3>事件通讯</h3><p>dora使用事件通讯来解耦业务线与业务线之间的关系,在subapp的config中可以定义事件来监听整个app的运行周期与自定义事件。</p><pre><code>componentDidShow () {
doraEvent.emit({
eventName : 'app:componentDidShow',
args : {},
});
}</code></pre><pre><code>event : {
'app:componentDidShow' : (arg) => {
console.log('subapp 启动');
console.log('持续检测用户当前订单是否偏离导航,触发安全机制。');
},
'app:componentDidHide' : (arg) => {
console.log('subapp 启动');
console.log('推入后台暂停检测');
},
},</code></pre><p>在小程序componentDidShow时候就会打印以下信息。</p><p><img width="723" height="268" src="/img/bVc86pS" alt="image.png" title="image.png"></p><h3>子父通讯与桥接</h3><p>dora使用ctx来桥接父与子仓库的通讯。</p><h4>setCtx</h4><p>在父应用中</p><pre><code>import useCtx from 'dora/export/useCtx';
setCtx({
moduleA: ()=>{
return '我来自父app'
}
})</code></pre><h4>useCtx</h4><p>在子应用中</p><pre><code><View className='index'>
我是subapp的页面
<View >
{useCtx().moduleA()}
</View>
</View></code></pre><h2>按业务线编译</h2><p>dora可以在编译点的时候设置环境变量 process.env.COMPILE_SUB_APP_NAMES来按照需求编译业务线,如果dora启动时有此环境变量那么dora只会集成此变量的业务线,以节约编译打包时间,每个业务线可以只编译自己开发的部分,在大型项目中非常实用,例如</p><pre><code>//父亲仓库config.json
{
"apps": {
"subAppA": {
"configPath": "./src/doraSubappExample/config.ts",
"path": "./src",
"repository": "git@github.com:gjc9620/dora-subapp-example.git",
"subAppName": "subAppA",
"tag": "1.0.0-release/1.0.0-1689675708545"
},
"subAppB": {
"configPath": "./src/doraSubappExample/config.ts",
"path": "./src",
"repository": "git@github.com:gjc9620/dora-subapp-example.git",
"subAppName": "subAppB",
"tag": "1.0.0-release/1.0.0-1689675708545"
},
"subAppC": {
"configPath": "./src/doraSubappExample/config.ts",
"path": "./src",
"repository": "git@github.com:gjc9620/dora-subapp-example.git",
"subAppName": "subAppC",
"tag": "1.0.0-release/1.0.0-1689675708545"
}
}
}
</code></pre><p>如 process.env.COMPILE_SUB_APP_NAMES ='subAppA,subAppB', dora只会集成subAppA、subAppB的业务线config,subAppC中的路由与config都会被忽略。</p><h2>原理</h2><p>dora使用git的tag功能,每次执行publish后就会执行git tag,生产一个tag后会记录在config.json中。当执行update时候,会把所有subapp的版本切换为父应用中的tag版本。</p><h2>团队协作流</h2><p>dora推荐的团队协作流程:<br><img width="723" height="296" src="/img/bVc86p9" alt="image.png" title="image.png"></p><p>(本文作者:顾嘉成)</p><p><img width="732" height="246" src="/img/bVc86qd" alt="image.png" title="image.png"></p>
哈啰云原生架构落地实践
https://segmentfault.com/a/1190000044064954
2023-07-31T15:21:22+08:00
2023-07-31T15:21:22+08:00
哈啰技术
https://segmentfault.com/u/hellotech
0
<h2>弹性伸缩技术实践</h2><h3>全网容器化后一线研发的使用问题</h3><p><img src="/img/remote/1460000044064956" alt="图片" title="图片"></p><p>全网容器化后一线研发会面临一系列使用问题,包括时机、容量、效率和成本问题,弹性伸缩是云原生容器化后的必然技术选择。</p><h3>使用原生弹性HPA遇到的问题</h3><p><img src="/img/remote/1460000044064957" alt="图片" title="图片"></p><p>当时第一时间考虑用原生HPA组件,但在实际调研和小规模使用的时候发现了很多问题。一方面是内置的问题,如原生不支持自定义指标和定时扩缩容,使用率计算基于resources.requests,使用单个Goroutine执行。更大的痛点在业务场景上,微服务在线实例拉出状态,特殊业务Job任务实例不能中断,要考虑下游DB层可用性。</p><h3>基于业务实例实际水位、有效负载的弹性能力</h3><p><img src="/img/remote/1460000044064958" alt="图片" title="图片"></p><p>我们基于业务实例实际水位、有效负载构建弹性能力。</p><ul><li>高低双阈值控制,像一些有浮动的业务,可以尽量把底层的稳定性去做有效的约束;</li><li>最大可用原则,扩容用Ceil向上取整,缩容使用Floor向下取整;</li><li>数据降噪,去除实例状态不ready的,去除强业务关系的实例计算,应用启动毛刺问题缓解,metrics空值、获取不到值;</li><li>性能增强,按业务namespace监听,实现并发度控制。</li></ul><h3>水位阈值弹性和定时弹性的融合实现</h3><p><img src="/img/remote/1460000044064959" alt="图片" title="图片"></p><p>这里同时做了水位阈值弹性和定时弹性的融合,保证单个的应用能够同时使用阈值和定时弹性。基本原则是扩容时大者取大,缩容时不能低于定时副本数。</p><h3>业务使用弹性后的生产效果</h3><p><img src="/img/remote/1460000044064960" alt="图片" title="图片"></p><p>业务使用弹性后产生了一定的效果,如图是高低阈值和定时的区间。这里解决了一些问题:</p><ul><li>代码预热,在高峰前准备就绪;</li><li>周期性波动型应用变更明显减少;</li><li>应用冗余减少,业务“初见原型”;</li><li>突发流量应对,稳定性提升;</li><li>告警、应用状态可控性。</li></ul><p>最终弹性接入90%以上,扩缩容消息触达率100%,业务可用算力增加了 30%以上。</p><h3>混合云模式下的 ClusterAutoScale</h3><p><img src="/img/remote/1460000044064961" alt="图片" title="图片"></p><p>集群可用资源变多,不等于整体开支降低,因此我们考虑去做混合云模式下的ClusterAutoScale。这里有一些关注点,包括镜像即服务、CloudProvider 适配、节点初始化和节点回收。</p><p><img src="/img/remote/1460000044064962" alt="图片" title="图片"></p><p>右图是基本流程,有两个触发策略,一是基于不可调度事件,二是基于资源池水位阈值。这里我们也解决了一些问题,包括 CloudProvider 对接私有云资源API,Pod网段分配域路由宣告,私有云可用容量评估及资源打散,以及资源灰度回收逻辑。</p><h3>注意事项</h3><p><img src="/img/remote/1460000044064963" alt="图片" title="图片"></p><p>在实际业务时生产使用的时候,有很多关注点,包括业务池的容量、应用维度的实例波动率标准、存活探测和就绪探测的接口区别、指标阈值和弹性规则的合理性巡检、不做过多filter、不强依赖其他系统平台。</p><h2>中间件容器化及混部填谷</h2><h3>业务独立池水位与混部预期</h3><p><img src="/img/remote/1460000044064964" alt="图片" title="图片"></p><p>针对业务特性,将redis和flink资源池进行融合,达到分时复用的效果。之前的形态会带来很多问题:</p><ul><li>每种业务独占一个资源池或集群,产生大量资源碎片,造成成本浪费;</li><li>多个集群多个对外API,数据需要多次聚合计算;</li><li>各业务池服务器规格不一致,运维管理复杂。</li></ul><h3>混部的总体思路</h3><p><img src="/img/remote/1460000044064965" alt="图片" title="图片"></p><p>混部里面这里做了一些思考,考虑了应用分级、资源需求、潮汐分布等等。将这些因子抽象归纳,分为应用分级、混部调度、资源QOS三层。我们也确定了几个总体方向,包括服务资源保障的策略实现和资源分配决策的算法实现。</p><p><img src="/img/remote/1460000044064966" alt="图片" title="图片"></p><p>在服务资源保障上,主要是对应用分级打标。我们把所有的业务做了S1-S4的分级,并落到了CMDB里,最终落到K8S的优先级标签里面。第二部分是资源池化,优先去考虑以底层的业务为重保,把一些优先级较低的应用分别打散到各个资源池。</p><p><img src="/img/remote/1460000044064967" alt="图片" title="图片"></p><p>在资源分配决策上, 第一部分是Request推荐。主要基于VPA Histogram计算百分位算法,通过获取7x24小时周期的应用资源量,根据P95百分位数据,再乘以水位系数放大后得到最终推荐值,并结合弹性、coolhealth状态机优化毛刺问题。</p><p><img src="/img/remote/1460000044064968" alt="图片" title="图片"></p><p>第二部分是实际负载调度,主要基于集群理想值权重算法和BinPacking装箱打分算法。过滤掉高水位节点,避免单node资源打爆;水位偏离度缩小,pod调度尽可能靠近理想水位;历史阈值计算应用负载,对节点未来水位预测;兼顾单node最大pod数限制。</p><p><img src="/img/remote/1460000044064969" alt="图片" title="图片"></p><p>第三部分是资源打散,通过问题推导,完全打散是不可能的,我们希望尽可能打散,在私有云IDC加入MDU策略。常用的策略有宿主机打散、可用区打散和MDU打散。</p><h3>成效和问题</h3><p>最终资源使用率有明显提高,成本账单同比持续降低。这里也带来了一个无法回避的问题:物理机宕机。爆炸半径增大,稳定性怎么保障,是我们基础设施的同学都需要去思考的问题。二是对根因下钻和故障定位带来挑战,如何观测和评估影响。</p><h2>K8S观测与稳定性</h2><h3>基于Prometheus的容器监控平台</h3><p><img src="/img/remote/1460000044064970" alt="图片" title="图片"></p><p>基于Prometheus构建了监控体系,核心组件包括Thanos + Prometheus 持久化存储、Vertex Exporter 指标采集数据源、SentryD 配置管理、CheckD 告警检测和Alerts 告警系统。</p><h3>监控高级模式</h3><p><img src="/img/remote/1460000044064971" alt="图片" title="图片"></p><p>我们自研了vertex采集工具,实现了快速生成metrics指标的能力,支持用户自定义指标名称,方便按业务、按分组区分。和exporter算力共享,每个实例 limit 2C/4G就可满足一个物理机的采集任务。</p><h3>event 事件流持久化</h3><p><img src="/img/remote/1460000044064972" alt="图片" title="图片"></p><p>实现了事件收集器,K8S全资源类型listwatch收集,同时把所有的event全量打印,针对特别的一些探针做了Response信息返回的打印。</p><h3>logs 日志平台</h3><p><img src="/img/remote/1460000044064973" alt="图片" title="图片"></p><p>把系统日志和业务日志等通过一些消费和采集的收集器,推送到kafka,最终聚合成一个平台。</p><h3>trace链路</h3><p><img src="/img/remote/1460000044064974" alt="图片" title="图片"></p><p>我们通过traceid查询,tags过滤进行数据检索分析;链路拓扑过滤,只看有错误的链路;采样链路搜索,链路分析。</p><h3>K8S稳定性关注的指标</h3><p><img src="/img/remote/1460000044064975" alt="图片" title="图片"></p><p>这里把K8S稳定性关注的指标分为五类,原生组件可用性、集群容量水位、集群资源负载、业务异常实例和云平台可用性。</p><h3>稳定性大盘</h3><p><img src="/img/remote/1460000044064976" alt="图片" title="图片"></p><p>云原生系统内维护的组件系统较多,一个原子管理单元发生问题后可能会影响多个上游链路系统。快速论证回答组件域当前是否正常,对于故障分析、问题定位有重要意义。</p><h2>未来的展望规划</h2><p>未来规划主要分为四部分,一是在离线的深度混部与调优,下一阶段还要持续推进哈啰内部云化中间件的混部进程,聚焦大算力应用的资源编排和成本优化。二是数据存储容器化,数据库、NoSQL的容器化工作,基于容器Cgroup隔离、以及类K8S资源编排模型的落地。目前哈啰内部已有部分业务开始生产化,还在持续建设中。三是Serverless业务场景模式探索,中后台的算法模型、数据任务job场景有一定的实践,业务大前端BFF层、无代码工程建设上在持续探索。四是基于AIOPS&可观测性的智能故障预测,基于时序预测模型能力,探索metrics异常指标提前发现,收敛告警系统误报、漏报问题,提升故障发现、故障定位能力。</p><p>(本文作者:罗涛)</p><p><img width="732" height="246" src="https://segmentfault.com/img/bVc7Tjs" alt="图片" title="图片"></p>
Java的AQS源码浅析
https://segmentfault.com/a/1190000044058051
2023-07-28T14:01:05+08:00
2023-07-28T14:01:05+08:00
哈啰技术
https://segmentfault.com/u/hellotech
0
<p>最近面试问过很多候选人Java锁有关的知识,可以感受到的是,大家的理解基本都停留在“八股文”的阶段,实质上对Java的锁以及多线程的同步机制这种底层原理,理解的不是很好。网上这类文章已经很多了,但是看了下有好多文章是过时的,典型的例如AQS里的addWaiter方法在JDK16里就没见到,或许代码进行了重构了。</p><p>通过文章也梳理一下我一般看源代码的习惯是怎样的。AQS全称是AbstractQueuedSynchronizer,首先他是一个抽象类,其次他使用了队列来进行排队,然后作用是用来做线程间的同步的。他是Java里所有锁的基础,包括CountDownLatch以及读写锁,可重入锁等等都是基于AQS实现的。我们从ReentrantLock入手来管中窥豹,大概得看看AQS的源代码。</p><p>首先你要了解的是使用方法,一个典型的ReentrantLock使用方法写在了这个类的注释里。</p><p><img src="/img/remote/1460000044058053" alt="图片" title="图片"></p><p>接着,你要有一个鸟瞰图,从大的层面看一看他的实现,ReentrantLock其实只是一层外层的包装,实际上内部具体是由Sync这个类来实现的,而这个类又有两个子类分别是FairSync和NonfairSync,可以看到这两个子类覆写的是initialTryLock和trayAcquire这两个方法,这说明这两个方法在实现上会有一些差异,也仅仅在这有些差别。</p><p><img src="/img/remote/1460000044058054" alt="图片" title="图片"></p><p>那么现在我们从两个方法入手,分别是lock和tryLock方法,这二者的区别是带try的只是试一下,如果能获取到最好,获取不到就算了,返回一个false告诉你没拿到锁。lock的实现如下:</p><p><img src="/img/remote/1460000044058055" alt="图片" title="图片"></p><p>可以看到他调用的正是子类的initialTryLock方法,接着看看initialTryLock方法,我们取的是NoFairSync,也就是他的默认实现。方法也很简单,通过CAS来尝试着获取锁,如果成功了就把当前的owner设置成本线程,否则的话如果失败了,check下当前的owner是不是本线程,如果是的话,直接state+1,实现了计数,也就是可重入的能力,如果都不是就返回false,获取锁失败了。失败了,就会调用acquire(1)。</p><p><img src="/img/remote/1460000044058056" alt="图片" title="图片"></p><p>这个方法则是由抽象类AQS提供的,方法如下 ,调用了子类的tryAcquire方法,我们还是看看NoFiairSync的实现。</p><p><img src="/img/remote/1460000044058057" alt="图片" title="图片"></p><p>这里会再次调用CAS来再次尝试一次。</p><p><img src="/img/remote/1460000044058058" alt="图片" title="图片"></p><p>如果还是失败了,则就进入了AQS的acquire的实现,这个方法最复杂也最难懂,是AQS的核心,一眼从参数上看上去就非常复杂,他兼容了share or not share,是否可中断,是否超时等等各种情况,因此复杂度就上来了,我们还是聚焦在当前的场景下,既他传入的是acquire(null, arg, false, false, false, 0L);不share,不中断,不超时....</p><p><img src="/img/remote/1460000044058059" alt="图片" title="图片"></p><p>方法体如下,贴了一部分,不贴全了。</p><pre><code>for (;;) {
if (!first && (pred = (node == null) ? null : node.prev) != null &&
!(first = (head == pred))) {
if (pred.status < 0) {
cleanQueue(); // predecessor cancelled
continue;
} else if (pred.prev == null) {
Thread.onSpinWait(); // ensure serialization
continue;
}
}
if (first || pred == null) {
boolean acquired;
try {
if (shared)
acquired = (tryAcquireShared(arg) >= 0);
else
acquired = tryAcquire(arg);
} catch (Throwable ex) {
cancelAcquire(node, interrupted, false);
throw ex;
}
if (acquired) {
if (first) {
node.prev = null;
head = node;
pred.next = null;
node.waiter = null;
if (shared)
signalNextIfShared(node);
if (interrupted)
current.interrupt();
}
return 1;
}
}
if (node == null) { // allocate; retry before enqueue
if (shared)
node = new SharedNode();
else
node = new ExclusiveNode();
} else if (pred == null) { // try to enqueue
node.waiter = current;
Node t = tail;
node.setPrevRelaxed(t); // avoid unnecessary fence
if (t == null)
tryInitializeHead();
else if (!casTail(t, node))
node.setPrevRelaxed(null); // back out
else
t.next = node;
} else if (first && spins != 0) {
--spins; // reduce unfairness on rewaits
Thread.onSpinWait();
} else if (node.status == 0) {
node.status = WAITING; // enable signal and recheck
} else {
long nanos;
spins = postSpins = (byte)((postSpins << 1) | 1);
if (!timed)
LockSupport.park(this);
else if ((nanos = time - System.nanoTime()) > 0L)
LockSupport.parkNanos(this, nanos);
else
break;
node.clearStatus();
if ((interrupted |= Thread.interrupted()) && interruptible)
break;
}
}</code></pre><p>他搞了一个死循环通过多次的循环+if条件来实现一个函数多个功能的目的。</p><p>第一次循环先走到这里,先初始化一个node。</p><pre><code>if (node == null) { // allocate; retry before enqueue
if (shared)
node = new SharedNode();
else
node = new ExclusiveNode();
} </code></pre><p>第二次循环到这里,将当前节点加入到tail尾巴上,并把prev指向上一个node,同时把上一个node的next指向当前节点,可以看到这个实现了一个链表。</p><pre><code>else if (pred == null) { // try to enqueue
node.waiter = current;
Node t = tail;
node.setPrevRelaxed(t); // avoid unnecessary fence
if (t == null)
tryInitializeHead();
else if (!casTail(t, node))
node.setPrevRelaxed(null); // back out
else
t.next = node;
} </code></pre><p>这里插一张网络上的图来说明,更助于理解这段代码,在AQS里有一个数据结构Node,这个数据结构有一个prev和next属性,实现了一个双向链表的功能,每一个调用lock的线程进来,如果拿不到锁就会加入这个队列进行等待,那么这个队列的操作过程就在上面的代码里。</p><p><img width="681" height="181" src="/img/bVc81AF" alt="image.png" title="image.png"></p><p>如果队列里没有了节点,当前就是头节点,又来了尝试去获取锁,就是下面这段代码。否则就调用park,挂起当前的线程。</p><pre><code>
if (first || pred == null) {
boolean acquired;
try {
if (shared)
acquired = (tryAcquireShared(arg) >= 0);
else
acquired = tryAcquire(arg);
} catch (Throwable ex) {
cancelAcquire(node, interrupted, false);
throw ex;
}
if (acquired) {
if (first) {
node.prev = null;
head = node;
pred.next = null;
node.waiter = null;
if (shared)
signalNextIfShared(node);
if (interrupted)
current.interrupt();
}
return 1;
}
}</code></pre><p>以上就是里面的比较核心的内容,如果是你来写,是不是很简单,如果是你会怎么写呢?这段代码比较诟病的地方就是同一个函数做了很多事情,包含初始化node节点,队列的更新,并且是通过多次循环+if来实现了,看起来很难懂。</p><p>最后,我们问自己几个问题:</p><p>1.锁释放的时候,会如何呢?代码也比较简单,如下,unLock的时候,传入1。</p><p><img width="723" height="385" src="/img/bVc81AP" alt="image.png" title="image.png"></p><p>每一次调用release(1)就会扣减一次,对应就是可重入锁的计数器的减少。同时考虑了异常调用,当别的线程试着去释放当前锁的时候,抛错。同时如果锁释放完了,将state设置为0,将当前线程置为空,彻底释放锁。</p><p><img width="723" height="367" src="/img/bVc81AQ" alt="image.png" title="image.png"></p><p>接下来就是SinalNext(head),这里使用的是LockSupport.unpark(s.waiter);唤醒我们的头结点,就是插入队列的第一个节点。</p><p>2.公平锁和非公平锁有什么差别呢?看了半天,我们发现无论是公平锁还是非公平锁,逻辑似乎都是一样的,都要入队列,都维持了队列,那么说好的非公平锁体现在哪里呢?</p><p><img width="723" height="598" src="/img/bVc81AU" alt="image.png" title="image.png"></p><p>原来在公平锁的获取锁的过程中,总是事先判断下队列是否为空,如果不为空则加入等待队列,而非公平锁则不一样,不管你队列空不空,先抢一把再说,有很大概率当前线程就直接抢到了锁,他就没有进入等待队列,这对于先到的线程来说,肯定不公平。所以很多候选人都回答,你公平锁因为要维持队列,所以性能更差显然是不准确的。即使非公平锁他们大概率都要入队列,维持队列,不同的仅仅是获得锁的等待时间不同而已,机会不一样。</p><p>总结一下,全篇的内容,主要介绍了AQS的部分实现机制,并通过ReentrantLock的实现简单讲解了AQS的源代码。Java的锁机制也都是通过一个volatile修饰的state变量,通过底层的CAS操作设置这个值实现了锁的功能。同时对于无法获取锁的线程则通过一个双向队列的维持,借助Java自身的park对这些线程设置为等待。在锁释放的时候,再去队列头通过unpark来唤醒该线程继续工作,就是这么简单,没什么神秘的。</p><p>最后用chatgpt生成的代码注释帮助理解。</p><pre><code>
/**
* 尝试获取锁或信号量,如果成功获取则返回1,否则继续尝试获取直至成功或被中断或超时
*
* @param node 当前节点
* @param arg 获取锁或信号量时的参数
* @param shared 是否是共享模式
* @param interruptible 是否允许被中断
* @param timed 是否使用定时等待
* @param time 定时等待的超时时间
* @return 成功获取返回1,超时返回0,被中断返回负数
*/
final int acquire(Node node, int arg, boolean shared,
boolean interruptible, boolean timed, long time) {
// 获取当前线程
Thread current = Thread.currentThread();
// 用于重试计数的变量
byte spins = 0, postSpins = 0;
// 用于标记当前线程是否被中断以及当前节点是否是队列中的首节点
boolean interrupted = false, first = false;
// 当前节点的前驱节点
Node pred = null;
// 循环尝试获取锁或信号量
for (;;) {
// 检查是否是队列中的首节点
if (!first && (pred = (node == null) ? null : node.prev) != null &&
!(first = (head == pred))) {
// 非首节点,检查前驱节点是否已取消或是有新的前驱节点
if (pred.status < 0) {
// 前驱节点已取消,清理队列
cleanQueue();
continue; // 重新开始循环尝试获取锁或信号量
} else if (pred.prev == null) {
// 确保串行化,避免过度自旋
Thread.onSpinWait();
continue; // 重新开始循环尝试获取锁或信号量
}
}
// 尝试获取锁或信号量
if (first || pred == null) {
// 首节点或前驱节点为null,说明当前节点还未入队列
boolean acquired;
try {
// 尝试获取锁或信号量
if (shared)
acquired = (tryAcquireShared(arg) >= 0);
else
acquired = tryAcquire(arg);
} catch (Throwable ex) {
// 取消获取操作,抛出异常
cancelAcquire(node, interrupted, false);
throw ex;
}
// 如果成功获取锁或信号量
if (acquired) {
// 更新头节点和前驱节点的引用,设置节点状态,唤醒其他等待线程
if (first) {
node.prev = null;
head = node;
pred.next = null;
node.waiter = null;
if (shared)
signalNextIfShared(node);
if (interrupted)
current.interrupt();
}
// 返回成功
return 1;
}
}
// 尝试入队或重试
if (node == null) {
// 未分配节点,分配新节点并重试
if (shared)
node = new SharedNode();
else
node = new ExclusiveNode();
} else if (pred == null) {
// 前驱节点为null,说明当前节点还未入队列,尝试将当前节点入队列
node.waiter = current;
Node t = tail;
node.setPrevRelaxed(t); // 避免不必要的内存屏障
if (t == null)
tryInitializeHead();
else if (!casTail(t, node))
node.setPrevRelaxed(null); // 入队失败,回退
else
t.next = node;
} else if (first && spins != 0) {
// 重试次数限制,减少对先前线程的不公平竞争
--spins;
// 进行自旋等待
Thread.onSpinWait();
} else if (node.status == 0) {
// 设置状态为等待中,以便其他线程进行唤醒
node.status = WAITING;
} else {
// 需要定时等待
long nanos;
spins = postSpins = (byte) ((postSpins << 1) | 1);
if (!timed)
LockSupport.park(this); // 非定时等待
else if ((nanos = time - System.nanoTime()) > 0L)
LockSupport.parkNanos(this, nanos); // 定时等待
else
break; // 超时,结束等待
// 清除节点的状态
node.clearStatus();
// 检查线程是否被中断,并根据需要退出循环
if ((interrupted |= Thread.interrupted()) && interruptible)
break;
}
}
// 取消获取操作,并根据中断状态返回相应结果
return cancelAcquire(node, interrupted, interruptible);
}
</code></pre><p>(本文作者:任天兵)</p><p><img width="732" height="246" src="https://segmentfault.com/img/bVc7Tjs" alt="图片" title="图片"></p>
哈啰地图服务组件 - LBS SDK
https://segmentfault.com/a/1190000044019508
2023-07-17T17:30:53+08:00
2023-07-17T17:30:53+08:00
哈啰技术
https://segmentfault.com/u/hellotech
0
<h2>基本介绍</h2><h3>背景介绍</h3><p>1.如何解决业务方在小程序、H5应用上的接入地图服务的困扰?<br>我们统一收口哈啰&高德服务能力,业务侧不需要每次接入LBS和高德服务都做http服务的接入工作,只用接入地图提供的组件包。我们还提供在类型声明,线接入文档,简化业务方的接入成本和工作量。</p><p>2.总是担心服务不稳定,如何缓解心智负担?<br>我们在前端侧优化了请求策略,加入了降级兜底能力,稳定性更有保障。</p><p>3.面对调用量增长,减轻服务压力,将如何面对?<br>我们从缓存方面入手,为调用量特别突出的逆地理设计缓存方案,通过缓存机制来减少调用,减轻服务压力。</p><h3>SDK介绍</h3><h4>基于哈啰&高德地图服务的封装库</h4><p><img src="/img/remote/1460000044019510" alt="图片" title="图片"></p><p>能力支持:</p><ul><li>核心是哈啰和高德地图服务</li><li>统一的出入参数据结构,数据差异抹平</li><li>高德服务兜底策略</li><li>逆地理编码缓存策略、请求缓存、结果缓存</li><li>完整的Typescript参类型声明</li><li>支持多环境下安装使用</li></ul><h4>Map Services</h4><p>我们现在有这么多地图服务能力,需要将其整合到一起。</p><p><img src="/img/remote/1460000044019511" alt="图片" title="图片"></p><h4>如何快速使用</h4><ul><li>我们的SDK项目有完善的TypeScript类型声明,可以让开发者在编写代码时享受到类型检查和自动补全的便利。</li><li>我们使用TypeDoc将类型声明生成对应的Markdown文档文件,再使用VuePress生成静态站点,方便开发者快速接入和使用。</li><li>未来,我们还打算添加在线文档接口mock的能力,可以编辑入参,发送请求,显示响应数据,让开发使用更便利。</li></ul><p><img src="/img/remote/1460000044019512" alt="图片" title="图片"></p><h4>请求策略是怎么样的</h4><p>为了保证地图组件的稳定性,地图组件对高德和LBS的请求策略进行了优化,在请求失败或超时的情况下,用高德服务做一次补偿(降级兜底),尝试再次获取数据。</p><p><img src="/img/remote/1460000044019513" alt="图片" title="图片"></p><h4>如何解决数据结构差异的困扰</h4><p>我们需要完成上图 -> 下图的数据转换,从高德数据转换为哈啰数据格式。</p><p><img src="/img/remote/1460000044019514" alt="图片" title="图片"></p><p><img src="/img/remote/1460000044019515" alt="图片" title="图片"></p><p>为了保证地图组件的通用性和可扩展性,地图组件对哈罗LBS和高德的出入参数据结构进行了统一,统一以哈啰数据结构为基准。</p><p>这样可以提高地图组件的兼容性和灵活性,同时也可以方便开发者使用。</p><p>我们定制开发了专门用来处理数据转换的工具库 @hb/map-convert。</p><ul><li>数据字段的转换</li><li>将数据转换为目标数据类型</li><li>对于一些数据类型完全不一样的内容,可自定义转换函数,保障数据转换工具的兼容性和灵活性。比如:高德返回值数据中比较典型的空数组问题处理,city:[] -> city: StringType() -> city: "",类似这种字段数据我们会进行类型统一</li><li>支持多层级的数据处理</li></ul><p><img src="/img/remote/1460000044019516" alt="图片" title="图片"></p><h2>前端缓存优化</h2><h3>逆地理编码缓存如何实现</h3><ul><li>选择一个合适的算法,用于生成缓存的CacheKey</li><li>选择一个合适的缓存容器,用于存储逆地理编码响应数据</li><li>选择合适的淘汰机制,用于数据的更新</li><li>根据CacheKey命中缓存</li></ul><p><img src="/img/remote/1460000044019517" alt="图片" title="图片"></p><h3>如何提升缓存命中效率</h3><p>通过GeoHash算法,进行一定误差范围内的经纬度匹配。</p><ul><li>GeoHash是一种地址编码方法,他能够把二维的空间经纬度数据编码成一个字符串</li><li>算法思想:GeoHash表示的并不是一个点,而是一个矩形区域,编码越长,表示的范围越小,位置也越精确,GeoHash编码的前缀可以表示更大的区域。例如wx4g0ec1,它的前缀wx4g0e表示包含编码wx4g0ec1在内的更大范围</li></ul><p><img src="/img/remote/1460000044019518" alt="图片" title="图片"></p><h3>GeoHash 算法原理</h3><h4>经纬度划分</h4><p>经度范围是东经180到西经180,纬度范围是南纬90到北纬90,我们设定西经为负,南纬为负,所以地球上的经度范围就是[-180, 180],纬度范围就是[-90,90]。</p><p>如果纬度范围用二进制代表:</p><ul><li>[-90°, 0°) => 0</li><li>(0°, 90°] => 1</li><li>[-180°, 0°) => 0</li><li>(0°, 180°] => 1</li></ul><p><img src="/img/remote/1460000044019519" alt="图片" title="图片"></p><h4>编码长度</h4><p>编码长度就是对方块的划分次数。</p><p><img src="/img/remote/1460000044019520" alt="图片" title="图片"></p><ul><li>根据设定的编码长度对当前经纬度分别进行划分,得到两组二进制串(10101、01010)后以偶数位放经度,奇数位放纬度的方式合并成一个二进制串(1001100110)</li><li>将二进制串划分每5位一组,不足5位补0(10011、00110)</li><li>将各组的5位二进制串转成十进制,5bits对应着10进制的数值为0-31(19、6)</li><li>用0-9、b-z(去掉a、i、l、o)这32个字母进行Base32编码,即对照下标将其转换为字符串(m、6)</li></ul><h4>编码长度 - 精度范围</h4><p>在逆地理缓存中编码长度为GeoHash9(5m左右误差)。</p><p><img src="/img/remote/1460000044019521" alt="图片" title="图片"></p><h3>缓存淘汰机制</h3><p><img src="/img/remote/1460000044019522" alt="图片" title="图片"></p><p>我们采用LRUCache缓存策略,它可以根据访问频率和时间自动淘汰最不常用的数据,保证缓存的空间利用率和数据的新鲜度。</p><p>原理解析:新数据插入到链表头部;每当缓存命中(即缓存数据被访问),则将数据移到链表头部;当链表满的时候,将链表尾部的数据丢弃。</p><p>其他维度的数据更新机制:</p><ul><li>时间维度:我们限制只使用2天内的数据,如超过则淘汰数据,重新请求并缓存</li><li>访问次数维度:我们限制数据使用10次后,会主动淘汰数据,重新请求并缓存</li></ul><h3>请求缓存</h3><p>计算CacheKey,缓存请求Promise,下一次请求调用发起时,先尝试命中缓存,若命中则返回上一次调用缓存下来的Promise。</p><p>更新机制:缓存只在一次请求生命周期内有效,请求成功或失败后缓存都将删除。</p><p>解决问题:同一接口重复调用的场景下,解决并发调用的问题,只要上一次响应没有返回,下一次就不会重复发起相同请求。比如:进入“打车”首页时多次调用某个接口的场景下;重复点击按钮执行某个操作的场景。</p><p><img src="/img/remote/1460000044019523" alt="图片" title="图片"></p><h3>响应结果缓存</h3><p>计算CacheKey,请求成功后,缓存响应结果,下一次请求调用发起时,先尝试命中缓存,若命中则返回缓存中的数据。</p><p>更新机制:使用LRUCache作为缓存容器,存储超过10条数据后陈旧数据将会被淘汰;缓存超过5分钟后失效;如果在小程序中,缓存随着小程序生命周期的结束而销毁。</p><p><img src="/img/remote/1460000044019524" alt="图片" title="图片"></p><h2>插件化设计</h2><h3>插件化的优点</h3><p><img src="/img/remote/1460000044019525" alt="图片" title="图片"></p><ul><li>提高了代码的可复用性和可维护性,因为每个插件都是独立的模块</li><li>降低了代码的耦合度和复杂度,每个插件都只关注自己的功能,不需要知道其他插件的细节</li><li>我们将一个网络请求的生命周期分为四个阶段:请求开始前、请求调用时、请求成功、请求失败。在每个阶段,我们可以使用不同的插件来做不同的事情</li></ul><h3>缓存插件</h3><p><img src="/img/remote/1460000044019526" alt="图片" title="图片"></p><p>在请求开始前,在onBefore生命周期中,我们可以使用缓存插件来检查是否有缓存数据,如果有则直接返回缓存数据,不需要发起网络请求。</p><p>在请求成功时,我们可以使用缓存插件来保存返回的数据到缓存中,方便下次使用。</p><h3>埋点插件</h3><p><img src="/img/remote/1460000044019527" alt="图片" title="图片"></p><p>在onSuccess生命周期中,使用ubt埋点插件来记录用户请求成功状态。</p><p>在onError生命周期中,使用ubt埋点插件来上报错误信息,方便后续分析和优化。</p><p>(本文作者:任赛龙)</p><p><img width="732" height="246" src="https://segmentfault.com/img/bVc7Tjs" alt="图片" title="图片"></p>
哈啰智能客服:如何应用语言模型提升机器人服务能力
https://segmentfault.com/a/1190000043931375
2023-06-25T10:30:50+08:00
2023-06-25T10:30:50+08:00
哈啰技术
https://segmentfault.com/u/hellotech
0
<h2>哈啰智能客服的总体介绍和算法流程</h2><h3>用户、算法眼中的智能客服痛点</h3><p><img src="/img/remote/1460000043931377" alt="图片" title="图片"></p><p>右图是哈啰APP的客服中心,用户进入该页面,系统会根据用户的使用情况智能推荐高频问题,并猜测用户想解决的问题,这部分标准问题的解决方案由业务专家进行整理,能涵盖用户大部分的意图。对于解决不了的问题,用户进入IM入口,聊天机器人会和用户进行对话。机器人基于知识库进行匹配,针对每个意图分别配置答案,或者给出具体解决方案。</p><p>目前的痛点在于:</p><ul><li>知识库迭代更新费时费力</li><li>模型难以跨业务通用</li><li>解决方案涉及到多模态的复杂数据融合问题</li><li>多轮任务型会话上下文的长距离依赖问题</li></ul><h3>用户在哈啰智能客服的历程</h3><p><img src="/img/remote/1460000043931378" alt="图片" title="图片"></p><p>用户进入热线或在线服务渠道,以线渠道为例,系统会预测用户想咨询的订单,并给出猜你想问和自助服务,如不能解决问题,会进入机器人服务。机器人链路包括query补全、精准匹配、分类模型、匹配模型和启发式问答,解决方案依托于知识库,可能是一套标准的服务流程,也可能需要判责,包括规则和智能判责。</p><p>机器人仍不能解决问题,会进入人工服务,我们用了NLP辅助人工客服更好地服务用户,如智能派单,并给出服务引导,在确认问题后实时推荐方案,用户确认方案后会进行话术推荐。如果不能解决需要升级到专门的客服,会生成摘要工单后移交。</p><p>此外,哈啰有一套利用众包模式的云客服系统,目前云客服受理占比达到70%。</p><h2>案例:意图识别 表示型文本匹配模型 > 分类模型</h2><p><img src="/img/remote/1460000043931379" alt="图片" title="图片"></p><p>意图识别可用分类做,也可用匹配。分类模型无法适应知识库变更、迁移性弱,而匹配模型能很好地克服这些缺点。</p><p>分类模型局限性:</p><ul><li>知识库变更无法及时响应,模型维护投入量大;</li><li>新标准问缺少训练数据,无法更新模型;</li><li>需要大量标注高质量数据,耗费人力大;</li><li>无法快速迁移到新业务。</li></ul><p>匹配模型优势:</p><ul><li>对知识库变更能及时响应,降低维护成本;</li><li>在新增标准问缺少训练数据情况下,也能进行识别;</li><li>可快速迁移到新业务,可做成通用模型,应用于所有业务;</li><li>可单纯通过增加相似问就能提高识别能力,易于优化。</li></ul><p>但是匹配模型有自己的问题。在克服准确率下降的困难后,我们匹配模型超越了分类模型的效果。</p><p>在实践中我们尝试了多种模型和优化方案,最终超越了线上分类模型的效果,在一条业务线的意图识别top1 准确率达到了82.21%。</p><h3>案例背景</h3><p><img src="/img/remote/1460000043931380" alt="图片" title="图片"></p><p>匹配模型分两类,各有自身缺点。交互型匹配模型准确率高,但计算量大,故而放弃。表示型匹配准确率一般不如分类,如何提升准确度成了我们思考的重点。</p><h3>表示型匹配模型落地流程</h3><p><img src="/img/remote/1460000043931381" alt="图片" title="图片"></p><h3>一系列优化措施提升准确率</h3><p><img src="/img/remote/1460000043931382" alt="图片" title="图片"></p><p>使用对比loss能够对效果有所提升,但还是远远不够。我们做了一系列实验,如图是实验的记录,发现领域内大规模预训练、扩充高质量数据和输入文本mask有效,增大句子长度和温度系数有一定效果,采样策略效果一般。</p><h3>成功要点 VS 无效尝试</h3><p>成功要点:</p><ul><li>超越线上分类模型(fastText),top1 准确率82.21% > 80.62%;</li><li>意图识别QPS高,精排匹配不适合,重点转向更好的编码表示;</li><li>预训练和数据质量始终是影响的大头;</li><li>多尝试,引入CV经验和各种tricks。</li></ul><p>无效尝试:</p><ul><li>尝试不同损失函数:tripletloss、bprloss,损失组合等;</li><li>调参:学习率,batch size;</li><li>模型选择:CNN、ALBERT、SentBert、ESIM;</li><li>其他逻辑:mask方式、拼接标准问等。</li></ul><h3>下一步启示</h3><ul><li>难负例是指距离小于一个较小阈值的负例,需要拉开;</li><li>知识库里不同标准意图(类)间,相似的样本少,导致难以区分;</li><li>l 受simCSE启发,可用dropout机制为难负例生成更多难负例。</li></ul><h2>案例:度量学习技术提升新意图发现的准确率</h2><p><img src="/img/remote/1460000043931383" alt="图片" title="图片"></p><p>用户经常会有新的意图,需要及时发现。我们希望建设有壁垒的知识库,需要重叠率低且覆盖率高,覆盖率高需要自动挖掘新意图。传统的做法是对未识别问题聚类,然后人工选出新意图,而我们的做法是用模型识别已知类和未知类,然后从未知类中选出。最终,我们推荐出的新意图占比提升50%,人工审核效率提升。</p><h3>案例背景</h3><p><img src="/img/bVc8uHN" alt="image.png" title="image.png"></p><p>传统聚类方法有一定的局限,人工审核效率低,推荐的新意图占比低。</p><h3>关键实践</h3><p><img src="/img/remote/1460000043931384" alt="图片" title="图片"></p><p>我们用分类的方式识别新意图,假设k个类是已知类,第k+1类是未知类,例如“车主为什么不接单”这类表述分类到k+1类。具体的做法是通过决策边界,到每个类别中心的距离d是否在所有边界外,判断是否为新意图。边界的半径由自适应学习而来。</p><p><img src="/img/remote/1460000043931385" alt="图片" title="图片"></p><p>我们进行了更好的语义特征表达,对『难』正负例进行采样。引入度量学习的三元组损失,每个batch选择跟锚点最远的正例,最近的负例。</p><h3>实验数据</h3><p><img src="/img/remote/1460000043931386" alt="图片" title="图片"></p><p>我们在snips、banking和oos等3个该领域的公开数据集进行了实验,随机选择25%、50%、75%的类别作为已知意图,其余都作为新意图。</p><p><img src="/img/bVc8uHR" alt="image.png" title="image.png"></p><p>随机x%的类作为已知类,剩下的未知类。80%的数据作为训练集,其余为测试集。第一个任务是做二分类,F1是对未知类,我们的整体正确率是最高的。第二个任务是做K+1分类,分别对已知、未知类计算F1,也是同样的结果。</p><h3>成功要点</h3><ul><li>自适应地确定决策边界,避免人为设置阈值的弊端;</li><li>利用度量学习,侧重于获得更加各向同性的意图表达。为后续分类和学习决策边界创造了条件;</li><li>可从T-SNE可视化印证。</li></ul><blockquote>T-SNE Visualization<br>Beneficial from deep metric learning, the intents of the same class are clustered close, and the intents of different classes are also well separable. Moreover, open intents are farther away from known intents.</blockquote><h2>案例:生成式模型用于NLP任务</h2><p><img src="/img/remote/1460000043931387" alt="图片" title="图片"></p><p>我们使用生成式模型辅助人工客服,通过域内学习哈啰的知识,并通过微调,提升域内表现。</p><h3>案例背景</h3><p>人工客服理解业务、规则难度大,成本高,而ChatGPT等大模型表现出惊人的对话能力和总结能力。但如何应用于公司业务,有两个问题待解决。一是IDC资源受限,RT要求快,中文效果好;用多大的大模型,这么大的模型是否够用不明确。二是如何在保持通用能力同时,学到公司的业务知识。</p><h3>开源基础模型评测</h3><p><img src="/img/remote/1460000043931388" alt="图片" title="图片"></p><p>我们对开源基础模型进行比较和评测。发现清华开源的ChatGLM-6B 参数较小,A100上RT 2s内,QPS也OK,中文任务支持高。</p><p><img src="/img/bVc8uIe" alt="image.png" title="image.png"></p><p><img src="/img/bVc8uIf" alt="image.png" title="image.png"></p><p>同时,我们对原生效果进行了评测,这里以语义分类任务和阅读理解任务为例。</p><h3>业务上优化</h3><p><img src="/img/remote/1460000043931389" alt="图片" title="图片"></p><p>一是在Prompt工程,给模型更清晰的提示。实体识别准确率有所提高,但指令遵从性较差,回复内容不可控,导致准确率低。</p><p><img src="/img/bVc8uIh" alt="image.png" title="image.png"></p><p>二是融入GPT4中文指令,并微调P-tuning。指令遵从性有所提高,但响应时间较长,影响体验。</p><p><img src="/img/bVc8uIi" alt="image.png" title="image.png"></p><p>三是学习哈啰知识,实体识别准确率和匹配准确率有所提高,但胡编几率较高,输出不可控。</p><p><img src="/img/bVc8uIE" alt="image.png" title="image.png"></p><p>四是增多高质量数据,匹配准确率大大提高,回答更可控。</p><h2>未来展望</h2><ul><li>基于知识库QQ匹配的意图识别技术已经很成熟。在专业领域内继续训练及微调,获取领域知识后,能够生成更好的回答,减轻人们的脑力负荷;</li><li>生成式大模型短期内不太可能直接为用户提供解决方案。因为业务的复杂性 经常超出想象,并且解决方案取决于多模态的数据。除了文本和图像,还和订单状态、用户画像、地理轨迹、点击行为、商品卡券等相关;</li><li>TaskMatrix提供思路,一系列解决方案可以抽象为APIs,它们和具体业务 数据相关。把LLM作为自然语言人机交互工具。LLM正确顺序调用正确的 API并给出解决方案,仍然有不少难点。</li></ul><p>(本文作者:王林林)</p><p><img src="https://segmentfault.com/img/bVc7Tjs" alt="图片" title="图片"></p>
Wukong 动态化组件能力实践
https://segmentfault.com/a/1190000043890918
2023-06-13T10:00:00+08:00
2023-06-13T10:00:00+08:00
哈啰技术
https://segmentfault.com/u/hellotech
0
<p><img src="/img/bVc8kbq" alt="image.png" title="image.png"></p><h2>导读</h2><p>在哈啰的APP中,活动、大促、周年庆等都需要我们能够拥有更快捷的响应速度、更高效的人力来缩短试错周期,而且流量区域运营位为了能够做到千人千面,又迫切的需要有一种可以根据不同的人群达到展示不同效果的目的。UI 可定制化、快速迭代、高性能体验一直以来都是移动端开发领域的核心诉求,随着哈啰业务的不断拓展,越来越多的业务线对上述三点提出了更高的要求,但由于移动端 App 发版的天然限制,无法很好满足业务方的诉求。</p><p>基于以上一些业务痛点,我们结合 Native 黄金流量业务区域对于性能极高的要求,输出了一套 Native 局部动态化方案 Wukong(悟空),截止到目前,已在App内部多个业务模块中得到验证。</p><h2>Wukong 是什么</h2><p>悟空动态卡片 (Wukong) 最初是为哈啰投放系统定制的 Native 高性能渲染引擎,是一套完整的跨端原生局部卡片动态展示的技术解决方案,以业务赋能为中心,致力于解决UI定制化、逻辑动态化、缩短试错周期、提升人效等相关问题,使得业务可以基于 Wukong 做到一次开发,随时上线,多端复用的效果。</p><h2>技术优势</h2><p>Wukong 在标准UI组件、JS 动态能力、样式支持等方面提供多种功能加持,同时支持自定义组件、自定义 JS Bridge的能力以满足更复杂、更加定制化的业务场景。</p><ul><li>支持 UI & 逻辑 动态发布,提高研发&运营效率</li><li>原生页面内嵌动态化视图的接入方式,接入成本低</li><li>Android / iOS 双端侧表现一致,支持实时预览,所见即所得</li><li>UI渲染纯 Native 实现,体积小、性能好、内存少</li></ul><h2>系统介绍</h2><p>Wukong 遵循了 CSS3 中提出的 Flexbox 布局规范,旨在磨平多平台在布局系统上的差异,为了达到一套模板双端一致的效果,对 Android 和 iOS 端组件样式的表现也进行了尽可能的统一,在开发方式上我们选择 XML 作为视图 DSL 的语言格式,同时定义了一整套的 xsd 规范来约束 XML 的编写,辅助开发编写过程中提前规避一些语法问题的同时也能更加容易进行入门使用。</p><p><img src="/img/remote/1460000043890920" alt="图片" title="图片"></p><p>如上图所示,在开发的过程中主要处理的是业务层相关的逻辑,模版的下载、缓存、校验,在 Wukong 内部有一套默认的实现,但并不是必须的,接入方完全可以根据自己的业务场景来进行覆盖重写,对于模版产物的管理是一个相对隐私的操作,为了达到最大化的灵活度,需要由业务接入方来扩展。</p><p>其中 SDK 内部我们设计了协议层,在初始化的时候来对接各种可插拔的能力,比如:自定义组件、自定义Bridge,同时将 SDK 内部的一些基础能力如:网络、埋点、路由、位置信息、设备信息等进行接口抽象,使得业务接入时可以方便的代替为自定义的通用基础能力。</p><h2>实现原理</h2><p>如果用比较通俗易懂的方式来介绍 Wukong 的大致实现原理,可以简单的理解为按照一定的约束规范编写的样式描述映射为 Native 的布局,从而达到样式逻辑动态化的效果。</p><p><img src="/img/remote/1460000043890921" alt="图片" title="图片"></p><p>通过 SDK 加载的流程如上图所示:</p><p>首先需要输入一个产物的链接地址和业务数据源,SDK 内部会通过模版管理模块来下载保存在服务端的模版,同时会对相关数据进行缓存(包括磁盘缓存和内存缓存),在拿到产物之后,通过解析器的处理会被映射为 VM(ViewModel) 对象,VM对象中包含了所有原始信息以及需要解析的 JS 表达式,为了将 VM 和 具体的View进行解耦,在 VM 和 View 之间还增加了一层 VNode 虚拟节点层,此时在生成 VNode tree 的时候,JS引擎会参与工作执行并获取 JS 表达式的结果,最终再将生成的 VNode tree 通过 Render 渲染为 Native 的 View tree。以上流程为初次加载的过程,当二次加载的时候,会优先使用缓存模版(包括磁盘缓存和内存缓存),同时,VNode tree 中通过 diff 计算差异来进行局部的更新,以达到性能的最大化。</p><p>其中有两个较为重要的环节:布局系统和JS引擎。对于布局系统来说,从能力、性能等多方面的对比考量,同时考虑到两端布局一致性以及降低开发者的学习成本,决定选择使用实现了 FlexBox 规范的三方库 Yoga作为我们的布局引擎,Yoga 作为一个通用的布局系统,来代替 iOS 上的 AutoLayout 和 Android 的 FlexBoxLayout,以此来保证既能拥有前端亲和性,也能达到多端表现一致性。由于 iOS 本身已经支持 JavaScriptCore,而 Android 则需要同时兼顾性能以及对于包体积的影响,所以采用 QuickJS 作为 SDK 底层的 JS 引擎。</p><h2>研发工具</h2><p>为了方便开发者快速的入门、开发、调试,同时保证模板的正确性和一致性, Wukong 为开发者提供了多个开发提效配套工具。在开发过程中采用的是工程化的管理方式,每一个工程都会包含一个 build 目录,用来存放生成的产物,通过脚本工具,可以初始化工程、解析工程、开启实时预览 Server,在一个工程中同时可以包含多个子工程,以便于在不同的动态卡片模版中快速实时的切换。</p><p><img src="/img/remote/1460000043890922" alt="图片" title="图片"></p><p>开发工具采用 VSCode,通过 xsd 和 lib.wukong.ts 的约束,更加便捷的做到代码补全和联想功能,配合实时预览工具,大大的提升整体的研发效率和研发体验。实时预览可以做到边开发边看效果的能力,在达到所见即所得的同时,其中的相关日志调试信息,也会同步的输出到控制台中,以便于研发人员跟踪排查问题。</p><p><img src="/img/remote/1460000043890923" alt="图片" title="图片"></p><h2>研发流程</h2><p>基于以上工具,我们将整个的研发流程尽可能的做到简单化、标准化:首先通过脚本来创建标准的项目工程或子工程,以防止代码管理上的混乱。在 VSCode 中编写模版代码,通过约束工具来达到代码解析前的合规化校验。使用脚本工具在工程中启动 Server,同时控制台中会输出当前链接到 Server的设备 log 日志信息。客户端通过预览工具连接 Server 达到实时预览的能力。修改相关代码后,再次通过脚本重新解析指定工程或子工程,预览工具会同步更新为最新的样式。最后一步就是将 build 目录中生成的产物配置到自己的管理后台,从而达到在线发布的能力。 </p><p><img src="/img/remote/1460000043890924" alt="图片" title="图片"></p><h2>发布流程</h2><p><img src="/img/remote/1460000043890925" alt="图片" title="图片"></p><p>在正常的 APP 迭代节奏中,需要 Android / iOS 各一名研发人员参与其中,同时还需要经过联调、测试、验收、接口发布、APP发布、数据回收等一系列的流程,周期过长,同时如果发布之后线上发现问题,还有可能会延迟到下个版本的发布周期,响应速度慢,无法很好的承接运营业务诉求。通过局部动态化能力,首先对于客户端来说相比之下仅需要一名研发参与需求模版的开发,减少了研发人力,其次也无需等待APP的发布周期,验收完成之后,可以在线实时更新,在保证原生用户体验的基础上不依赖发版、高效支撑业务需求,大大缩短业务研发和数据回收的时间周期,新功能也可在任意线上版本中生效,产品验证和推广更高效。</p><h2>总结和展望</h2><p>Wukong 从创建之初到现在历经一年多的开发维护时间,中间遇到过各种不同的需求和技术问题,在团队伙伴的集思广益之下才逐渐成型,再次表示由衷的感谢。目前 SDK 在哈啰APP首页、钱包、个人中心、搜索等业务中广泛使用,业务研发整体提效 50%,尤其对于投放系统,在高流量运营区域位活动研发提效达 80%,随着 Wukong 使用场景的逐渐增多,在技术上我们会不断丰富能力,持续优化整体性能,进一步降低学习和使用成本,提供更完善的配套设施,同时也希望各领域的专家,能够提出宝贵的建议共建共创,协助我们通力打造一个更加稳定、易用且完整的局部动态化能力技术生态。 </p><p>相关链接:</p><ul><li>GitHub: <a href="https://link.segmentfault.com/?enc=pzIbI8K0ghtrnJvXJ7t8DQ%3D%3D.1QeQ49WjjAuSAcZpneURlzJSsubfYC%2FAFQOaUtlZsTDZt0OQJE0Uq5y96pcKyRW%2F" rel="nofollow">https://github.com/hellof2e/Wukong</a></li><li>Wukong DOC:<a href="https://link.segmentfault.com/?enc=Ba7LuCJIEbwu4QilZifN8Q%3D%3D.71brGpwuZJs4%2F8rug168MPjrGyyV09T6WC3EKzO3YkXpttCpyGSLYHufdRKPWB%2F6bgYbM8xdC53q9yPeEZC6Yid8u%2F606I4Tv1%2Byzfdkl4I%3D" rel="nofollow">https://hellobike.yuque.com/org-wiki-nlsyth/hec0gc/mput57lpnz...</a></li><li>layout:<a href="https://link.segmentfault.com/?enc=KiVFcuQmVwfdPz3swwWWnA%3D%3D.cTKdEGlEmuMmiKe9UxxWPrbNInV3gm21hldXfSsxMfY%3D" rel="nofollow">https://yogalayout.com/</a></li><li>quickjs:<a href="https://link.segmentfault.com/?enc=CjJAHIwBkDVL%2FeEsXiwkuA%3D%3D.LvvdpA6n%2FJheSOAn%2FUK4zYSRfr%2Be%2BvFWdYfTW7wz98g%3D" rel="nofollow">https://bellard.org/quickjs/</a></li></ul><p><img src="/img/bVc8kbY" alt="image.png" title="image.png"></p>
基于Electron开发桌面应用的技术实践
https://segmentfault.com/a/1190000043841142
2023-05-29T17:21:12+08:00
2023-05-29T17:21:12+08:00
哈啰技术
https://segmentfault.com/u/hellotech
1
<p>哈骑士是哈啰的一款终端安全应用,本文主要介绍我们在做新版哈骑士桌面端时的一些技术架构思考和实践,分享我们沉淀的一些桌面端应用的解决方案和经验。</p><h2>为什么选择Electron</h2><h3>前端开发者入门快</h3><p><img src="/img/remote/1460000043841144" alt="图片" title="图片"></p><p>Electron是一个使用 JavaScript、HTML 和 CSS 构建桌面应用程序的框架。嵌入 Chromium 和 Node.js 到 二进制的 Electron 允许您保持一个 JavaScript 代码代码库并创建 在Windows上运行的跨平台应用 macOS和Linux——不需要本地开发经验,有了它,前端开发者就可以使用前端开发技术来开发桌面应用了。</p><h3>支持跨端&开发效率高</h3><p><img src="/img/remote/1460000043841145" alt="图片" title="图片"></p><p>如上图所示:</p><ul><li>Native(C++/C#/Objective-C)不管从原生体验、包的体积、性能方面来说都是最佳的选择,但是开发门槛高、迭代速度慢。</li><li>QT是基于C++的跨平台开发框架,跨平台应用十分广泛(Mac、Windows、ios、Android、Linux、嵌入式),众所周知的WPS就是用QT开发的。性能很好,甚至于可以媲美原生的体验,但是整体门槛还是比较高的。</li><li>NW也是一个跨平台的框架,但是其社区以及解决方案相对于Electron来说并不是那么强大,而且所有的非javascript编写的模块都需要重新用nw-gyp重新编译,相对于Electron来说,不是那么灵活。</li><li>Tauri也是一个非常火爆的跨平台的桌面端框架,相对于Electron来说还不是那么成熟,生态方面也略显青涩,兼容性问题有待考证。</li></ul><p>作为一个跨平台的桌面应用开发框架,Electron 的迷人之处在于,它是建立在 Chromium 和 Node.js 之上的,二位分工明确,一个负责界面,一个负责背后的逻辑。虽然系统间还是会有很大的差异,需要相应地做一些额外处理,使得打包出的应用在不同系统下都能正常运转,但相比于 80% 都能完全复用的代码,这些时间和成本都是可以忽略的,开发效率直接翻倍,如果你开发一个不需要太关注底层的桌面端应用,基本不需要做底层的抹平逻辑。</p><p><img src="/img/remote/1460000043841146" alt="图片" title="图片"></p><p>另外,Electron 是基于 Node.js 的,这就意味着,Node 这个大生态下的模块,Electron 都可以用。同时,跨平台也让 Electron 可同时开发 Web 应用和桌面应用,无论是 UI,还是代码,很多资源都可以共享,大幅减少了开发者的工作量。</p><h3>生态繁荣&案例成熟</h3><p><img src="/img/remote/1460000043841147" alt="图片" title="图片"></p><p><img src="/img/remote/1460000043841148" alt="图片" title="图片"></p><p>Electron生态的确很强大,各种库和工具包都为你构建一个桌面端应用提供了很多方案。</p><p><img src="/img/remote/1460000043841149" alt="图片" title="图片"></p><p>当然,不止如此,现在用Electron做桌面端的案例也非常成熟了。上图已经说明了Electron应用是有多广泛了,这其中不乏大名鼎鼎、如雷贯耳的应用,例如 Postman、Skype、VScode 等。而且我敢打赌,各位看官的电脑上一定安装过用 Electron 开发的应用,如果你用的是 Mac 电脑,请在命令行运行下面的命令来检测本地采用 Electron 技术开发的桌面软件:</p><pre><code>for app in /Applications/*; do;[ -d $app/Contents/Frameworks/Electron\ Framework.framework ] && echo $app; done</code></pre><h2>Electron生态开发技术选型</h2><h3>脚手架选型</h3><p>关于脚手架的选择,其实也很多。</p><p>官方提供的有Electron Forge,Electron Fiddle,electron-quick-start,其实如果你的应用不复杂,可以用官方的脚手架生成一个快速上手的模版,然后就可以愉快地开发了。</p><p>当然也有一些开源的脚手架,比如electron-vue或vue-cli-plugin-electron-builder之类的,也可以让你快速的生成一个固定的模版,然后往里面填充你的内容。</p><p>个人认为,官方的脚手架工具可以用来尝鲜,学习使用,electron-vue这类工具,如果是在一个企业级的项目中使用,前期会给你带来便利,但是后期扩展不会太友好,另外就是他们是基于webpack构建的工具,在日常的开发和使用中会觉得编译得不够快(相对于Vite)。</p><p>另外就是如果你想自己完成一个项目脚手架(项目框架),完全可以凭借自己的经验或者参考开源项目的架构自己来完成一个脚手架,一来是为了更加了解Electron的构建原理,二来是可以搭建出适合自己风格项目的脚手架,后期利于扩展和丰富。</p><p>所以我们脚手架的选型就是自己来造一个Electron的项目架构,从package.json开始,用Vite+Electron+React构建一个Electron项目。</p><h3>网络模块选型</h3><p>Electron发送HTTP请求的方案有很多。</p><p>第一种就是渲染进程和主进程分别用相应的请求HTTP请求工具来进行网络请求,比如渲染进程可以使用fetch,主进程用net模块。这种方案的优点就是可以把渲染进程和主进程的请求分开,分工明确,而且调试也方便,渲染进程可以直接看network;缺点就是,如果要对请求进行统一封装的话,比较麻烦。</p><p>第二种就是所有的请求统一封装,如果你都使用net模块或者其他的请求工具包对请求进行统一的封装,然后主进程直接使用,渲染进程调用统一的桥接方法。这种方案就是完全可以统一请求封装,但是如果想调试的请求的话,不方便,需要在主进程来日志信息。</p><p>第三种就是,直接axios直接一把梭,它既支持node环境,也支持浏览器环境。这种方案非常方便,你就按照之前封装Web应用请求的思路去封装自己的请求模块就行,不过需要注意跨域问题。</p><p>对于上面的几种方案,各有各的优缺点,可以根据自己的场景需求来决定使用哪种方案。我们选择了axios来设计网络请求模块。</p><h3>本地数据库选型</h3><p>Electron的本地数据存储方式也有很多种,可以直接读写文件,也可以用相关的库,方便数据管理。一些库的对比,详情:<a href="https://link.segmentfault.com/?enc=NoSBZuq0XW1WQfYR0Q9vuw%3D%3D.py1%2FNyaGSOqDx2M0bN61DK9pd6%2B5SW7LyIshmRa0jOPgRftv63I2hyeLr78UpG0IbzPl6xFE1Na%2BZVPcJ3G1aGRGrJCQ5Yu86070X6YGl0I%3D" rel="nofollow">https://www.npmtrends.com/electron-store-vs-lokijs-vs-lowdb-v...</a>。</p><p><img src="/img/remote/1460000043841150" alt="图片" title="图片"></p><p>综合来看lowdb更胜一筹,所以选择lowdb做本地数据库,非常好的一点是它支持同步,不必担心数据没有写入就进行了下一步需要本地数据的业务操作。</p><h3>日志工具选型</h3><p>日志工具对Electron的开发也是尤为重要的,可以给你定位到一些表层无法定位的问题,所以一款好的日志工具对开发是非常有帮助的。</p><p>比较常见的日志工具就是electron-log和log4js-node,这两款日志工具我都有用过。可以看下npm的排行,这里把express-winston和logging也加上看一下,详情:<a href="https://link.segmentfault.com/?enc=LDUWZwpw3zQWhzUfbY4yEA%3D%3D.uPSG69uKjdFFHoLjzUXgz6GN4ldyFJR%2B%2BJ0PN8Vk%2BqV6uBWrySbwxHyidXwFfDE4YpCsmMDCpKCpOgQmOESX%2BnjBx9Mww8B2IAIgjhbj0j4%3D" rel="nofollow">https://npmtrends.com/electron-log-vs-express-winston-vs-log4...</a>。</p><p><img src="/img/remote/1460000043841151" alt="图片" title="图片"></p><p>这里简单说一下electron-log和log4js-node的比较,两者上手都比较简单,log4js-node暴露的API 非常多,electron-log就稍显逊色了,另外最直观的感受就是,electron-log的日志文件路径不好找,暂时没发现自定义日志路径的方法,log4js-node有相应的方法,而且你可以自定义各种文件类型。根据使用体验,觉得log4js-node更好,推荐log4js-node。</p><h3>构建工具选型</h3><p>三种构建工具electron-builder, electron-forge, electron-packager 对比一下。</p><p><img src="/img/bVc77eF" alt="image.png" title="image.png"></p><p>从这个排行来看electron-builder的确很强,electron-forge最近又更新大的版本,不过没有尝鲜,我在electron-builder上倒是踩了不少坑,可以分享给大家。所以我在开发的时候选择的构建打包工具是electron-builder,它把整套解决方案都集成了,包括打包、更新、签名、分发,基本的钩子和配置都有相应的暴露。</p><h2>核心架构实现</h2><h3>架构概览</h3><p><img src="/img/remote/1460000043841152" alt="图片" title="图片"></p><p>我们整个框架是基于Eletcorn + Vite构建的,在底层依赖的安全能力和存储模块的基础设施之上设计了一层基础框架,实现构建打包,架构分层的设计,然后给整个桌面应用提供一些应用管理能力和GUI管理相关的能力,最上层就是为了一些业务场景提供的一些应用能力,包括核心的几个应用和主要的策略引擎应用(终端策略和合规策略)。</p><p>开发构建Electron是多进程架构的体系,所以我们在开发构建的时候就是构建多个进程来实现我们的应用。核心思路是通过Vite构建三个进程:渲染进程,任务进程,主进程,然后最后将三个进程融合起来,就形成了一个应用。核心代码如下:</p><p><img src="/img/remote/1460000043841153" alt="图片" title="图片"></p><p>几个注意点:</p><ul><li>我们这里利用了writeBundle,就是等chunk都写入文件后,再启动Electron进程。</li><li>这里没有利用Electron的命令启动,而是通过Node.js的child_process模块的spawn方法启动Electron子进程,主要是因为我们需要依赖开发环境的渲染进程。</li><li>另外就是config/vite/main.js中需要对rollupOptions的external进行electron的配置,把导入包转成外部依赖,不然在启动Electron会找不到Electron的路径。</li><li>在createMainServer中我们注入了全局可使用的变量,以便Electorn加载页面的时候可以使用这些变量。</li></ul><h3>架构分层</h3><p><img src="/img/remote/1460000043841154" alt="图片" title="图片"></p><p>因为需要跨端开发,Mac和Windows有些底层模块的实现还是有不一样的地方,所以我们在开发设计的时候将代码进行了分层设计,这样至上而下的调用在上层看来是一样的,所以我们需要磨平端上底层的差异,现阶段我们底层模块的实现是通过目录来严格区分的,这样在开发一个底层的功能的时候就可以做到各段相互不影响。</p><h3>打包升级</h3><p>桌面客户端相当于传统的Web应用在打包和更新这一块还是有非常大的不同的,传统的web应用几乎不用所谓的升级,浏览器刷新页面即可,但是桌面客户端就需要完整的给用户一个可以立即执行的安装应用程序,而且还要可持续迭代和更新,所以在打包升级这一块,我们也是踩了不少坑。</p><p><img src="/img/remote/1460000043841155" alt="图片" title="图片"></p><p>1.关于打包<br>打包其实Electron的生态也是非常成熟的,如上面提到的构建技术选型,我们选择的是electron-builder,它提供了一套打包构建升级的流程,暴露了很多API,傻瓜式的配置就基本可以让你实现一个应用的打包了,唯一麻烦的就是签名和认证应用。</p><p>在Windows端我们使用pfx格式的证书进行认证,在进行打包的时候会和证书客户端软件交互,完成各个文件的签名,这样用户使用客户端的时候就是签名过的软件了。</p><p>在Mac端我们需要使用苹果认证的开发者证书进行签名和认证,配置相应的identity后,构建打包的时候会直接跟你本地的证书进行交互,然后对文件进行签名,当前我们还需要让应用可以不必严格使用 MAP_JIT 标识也能写入和运行内存内容。所以需要加入entitlements和entitlementsInherit。</p><pre><code><?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.security.cs.allow-unsigned-executable-memory</key>
<true/>
</dict>
</plist></code></pre><p>到这一步其实Mac端的软件签名就完成了,但是如果应用想App Store上架的话还需要对应用进行公证。公证主要是使用electron-notarize来进行公证,启用afterSign即可,afterSign: './script/notarize.js',</p><p>下面的Apple ID就是你的开发者账号,appleIdPassword需要生成一个专用的应用密码,不要使用你本来的Apple ID密码。</p><pre><code>const { notarize } = require("electron-notarize");
exports.default = async function notarizing(context) {
const { electronPlatformName, appOutDir } = context;
if (electronPlatformName !== "darwin") {
return;
}
const appName = context.packager.appInfo.productFilename;
console.log(\`公证中...\`)
return await notarize({
appBundleId: "mac.hellobike.knight",
appPath: \`${appOutDir}/${appName}.app\`,1
appleId: "XXXXX@outlook.com",
appleIdPassword: "XXXXX",
});
};
</code></pre><p>notarize会根据你的配置去校验你的应用是否可以公证成功,公证的时候会和苹果的服务器进行通讯,所以需要保持网络不要断开,成功或者失败之后都会发送相应的邮件到你的开发者邮箱里面。到这里打包的核心工作就做完了,如果你需要其他个性化配置,参考electron-builder官方的文档即可。</p><p>2.关于升级<br>升级我们在Mac和Windows上的实现各有不同,因为相比于传统的软件,我们哈骑士会一直保活在用户的进程中,所以在更新升级的时候也会打破原本Electron升级的机制。</p><p>在Windows上其实还好,可以利用electron-updater本身的生命周期来完成下载,更新,重启应用,因为Windows的保活是用另外的服务来实现的,所以并不会对整个更新周期产生破坏性的影响。</p><p>但是Mac端的保活实现是打破了electron-updater本身的生命周期的,探究其源码会发现Electron自己的升级服务其实也是一个保活的应用服务,所以在升级之前需要将其Kill后才能完成哈骑士自己本身的更新逻辑,另外就是文件占用和锁定的问题,为此我们自研了一套更新脚本程序结合electron-updater的下载更新的能力实现了Mac端软件的升级。</p><h2>核心能力沉淀</h2><h3>基础能力</h3><p>我们在做哈骑士客户端的时候,也沉淀了一些与业务无耦合的组件和工具类,这些组件和工具在桌面端应用的场景都比较通用。</p><p><img src="/img/bVc77fe" alt="image.png" title="image.png"></p><ul><li>本地数据库管理<br>本地数据存储是业务场景中随处可见的重要功能。为此,我们封装了常用的增删改查数据库的能力,并提供给各个进程使用,以实现数据持久化存储。</li><li>底层桥接<br>底层桥接是解决Electron和Node无法覆盖所有应用场景的必要手段。我们在桥接层封装了三种桥接模式,分别为渲染进程调用的jsBridge能力、主进程调用dll和dylib插件的能力,以及桥接rust程序的能力。这三种模式基本上可以解决所有技术瓶颈。</li><li>客户端请求<br>客户端请求模块也是至关重要的。我们将其封装成了通用的http请求库,支持主进程、渲染进程和任务进程的调用,以抹平上层调用的差异性。</li><li>任务管理<br>由于业务场景和客户端的特殊性,我们经常需要进行本地任务管理。因此,我们将任务管理模块封装成了通用的工具类,以支持对任务的注册、启动、停止和销毁等各项生命周期的管理。</li></ul><h3>应用能力</h3><p>在上面这些基础能力的组合应用下,我们形成了一个强大的策略引擎应用。</p><p><img src="/img/remote/1460000043841156" alt="图片" title="图片"></p><p>该策略引擎应用实现了端上任务调度和分发功能。首先接收后台配置的策略信息,然后生成对应的任务,并分发到各个子任务中心以执行对应的策略。最后,将策略执行情况报告给服务端。</p><h2>总结</h2><p>Electron在哈骑士的应用非常成功,虽然在使用过程中遇到了一些问题,但不可否认它是目前最适合我们业务目标和开发资源的框架。使用Electron使需求交付效率得到了很大的提升。我们也将持续关注性能和稳定性的优化、桌面端全链路日志的完善以及增量更新升级能力等方面的改进。</p><p>(本文作者:徐涛焘)</p><p><img src="https://segmentfault.com/img/bVc7Tjs" alt="图片" title="图片"></p>
哈啰一站式AI平台在多端智能的探索
https://segmentfault.com/a/1190000043787583
2023-05-15T11:04:51+08:00
2023-05-15T11:04:51+08:00
哈啰技术
https://segmentfault.com/u/hellotech
0
<p>近年来随着大数据时代的到来和计算能力的提升,人工智能在各个领域都取得了显著的进展。原先在云端进行特征的存储与处理、模型的训练、在线推理,在客户端进行数据的展示的架构展现出越来越多的缺点和局限性。本文将结合端智能的优势,结合哈啰一站式AI平台的现状,讲述一站式AI平台如何支持多端智能(服务端、flink端、移动端)。</p><h2>云端推理模式</h2><h3>具体流程</h3><p><img src="/img/remote/1460000043787585" alt="图片" title="图片"></p><p>图1-1 云端推理模式流程图</p><ul><li>首先云端会将客户端上报的的离线特征和在线特征数据进行存储</li><li>云端将离线特征数据喂给模型进行模型训练</li><li>模型在云端训练完毕后进行模型上线或者模型更新</li><li>客户端触发在线推理请求调用云端进行在线预测</li><li>云端在线推理结合特征数据和模型文件进行预测</li><li>云端返回排序结果和数据在客户端展示</li></ul><h3>问题</h3><ul><li>带宽和延迟问题:所有客户端用户的请求都要先到云端进行推理后,才能在客户端进行展示,网络传输存在一定耗时;若推理所需特征较大(如图片,视频等),会导致延迟很高,以及网络带宽压力非常大</li><li>数据安全:用户的数据都需要通过网络传输上传到云端,存在泄漏风险</li><li>数据隐私:部分数据为用户隐私数据,无法存储在云端</li><li>成本问题:所有的在线推理都在云端,则云端需要部署大量服务器提供在线推理能力,成本巨大</li><li>中心化问题:所有的在线推理都需要走云端接口,若网络异常或者云端服务异常,则会导致推理能力失效</li></ul><h2>端智能模式</h2><p>端智能技术是指将计算、存储和推理从中心化的云端转移到网络边缘设备(如智能手机、物联网设备等)的技术。</p><h3>具体流程</h3><p><img src="/img/remote/1460000043787586" alt="图片" title="图片"></p><p>图1-2 端智能推理模式流程图</p><ul><li>首先云端会将客户端上报的的离线特征和在线特征数据进行存储</li><li>云端将原有的离线特征数据和新上报的离线特征数据喂给模型进行模型训练</li><li>模型在云端训练完毕后的模型进行模型上线或者模型更新</li><li>客户端定时触发模型版本更新,云端识别到客户端模板版本过旧会告诉客户端需要更新模型</li><li>客户端定时触发云端特征查询,会将云端查询的数据和历史数据存储起来</li><li>客户端结合特征数据和模型文件进行在线推理</li><li>客户端进行排序和结果展示</li></ul><h3>端智能优势</h3><ul><li>客户端的在线推理依赖的特征和模型都在本地运行,无网络带宽和延迟问题</li><li>数据本地化存储,无数据安全和隐私问题</li><li>在线推理本地化,云端故障或者网络故障不会导致无法在线推理</li><li>在线推理本地化,云端无需提供服务,可降低成本</li></ul><h2>哈啰一站式AI平台当前现状</h2><h3>整体架构</h3><p><img src="/img/remote/1460000043787587" alt="图片" title="图片"></p><p>图2-1 一站式AI平台架构图</p><ul><li>一站式AI平台从划分来说划分为训练平台、模型平台、特征平台、决策平台</li><li>训练平台<br>提供离在线模型的训练、模型开发环境资源管控、模型训练资源管控、离线模型推理、离线模型管理、分布式训练、分布式预测</li><li>模型平台<br>提供tf1/tf2、pmml、python、python-gpu四类在线模型的模型管理、模型资源管控、在线推理能力</li><li>特征平台<br>提供离在线特征存储、实时特征计算、特征关联、特征加工、特征选择、特征清洗、特征查询等能力</li><li>决策平台<br>基于DAG流程编排能力,提供了串联groovy脚本、多模型,多特征的流程编排,统一对外提供在线推理能力</li></ul><h3>整体流程</h3><p><img src="/img/remote/1460000043787588" alt="图片" title="图片"></p><p>图2-2 AI平台简易流程图</p><p>如图2-2所示,表示了简易的一站式AI平台的流程图。</p><p>1.特征创建和变更</p><ul><li>在线特征存储:用户在AI平台先创建特征,将离线特征转为在线特征存储起来</li><li>在线特征新增和变更:特征和特征存储的关系会通过AI平台的配置变更,同步给在线特征服务FeatureService</li></ul><p>2.模型训练和创建,以及模型版本管理</p><ul><li>模型训练:用户在AI平台通过远端任务触发模型的训练,训练完成后产生模型文件,生成本地文件或者上传到oss</li><li>新增模型:用户在AI平台通过新增模型模块将本地模型文件或者oss模型文件和模型绑定</li><li>模型变更:用户可通过离线训练方式手动在AI平台更新模型,也可以通过定时任务训练模型后触发自动模型更新</li></ul><p>3.在线推理</p><ul><li>用户在决策平台先绑定模型和特征的关系以及groovy规则的关系,生成决策</li><li>业务服务调用决策服务,经过groovy规则、灰度、特征查询、特征预处理后传给模型进行在线推理</li><li>决策服务将推理结果返回给业务系统</li></ul><h2>移动端智能</h2><h3>整体流程</h3><p><img src="/img/remote/1460000043787589" alt="图片" title="图片"></p><p>图3-1 AI平台移动端智能方案简易流程图</p><p>如图3-1所示,表示了移动端智能方案简易流程图,和2-1对比差异点如下。</p><p>1.模型模块</p><ul><li>模型版本管理:原先模型版本管理是在一站式AI平台完成,业务调用方只需要给出决策id就能知道可运行的模型,而端智能后,前端需要配合进行模型管理</li><li>模型文件分发:原先模型文件产生后,会将模型文件分发到云端的模型服务ModelService,端智能后需要将模型文件分发到每个用户的移动端</li><li>模型运行环境:原先模型的运行环境是在ModelService集成的,现在模型在线推理在移动端,需要在移动端支持模型的运行环境</li></ul><p>2.特征模块</p><ul><li>特征的存储原先只在云端,端智能后端上会存储数据</li><li>在线推理不再依赖云端数据,而是依赖本地特征数据(端智能后移动端会定时拉取云端特征到本地)</li></ul><p>3.决策模块</p><ul><li>原先决策是作为服务给业务方调用,端智能后需要打成sdk包给移动端调用</li><li>决策服务原先只给后端服务调用,端智能后需要支持移动端请求访问</li></ul><h3>挑战和方案</h3><ol><li>端上版本更新挑战<br>在端上运行模型会依赖特征数据、特征预处理逻辑、模型文件、groovy规则,依赖库会有依赖关系,如果部分是实时分发更新,部分是依赖移动端发版,则可能会导致不一致,且依赖移动版发版时间。</li></ol><ul><li>方案<br>将可以动态发布的模型文件、特征预处理文件,groovy规则,依赖库打包成一个算法包,支持动态更新和统一版本管理</li></ul><ol start="2"><li>模型文件分发挑战<br>从ModelService转为移动端,数量大大增加。ModelService为云端系统pod数,基本是几百,移动端是用户收集app,则会在千万级别。</li></ol><ul><li>方案<br>云端通过用户手动或者自动训练完模型后更新的oss,用户打开App后定时check算法包是否存在更新,若存在更新,则从用户最近的CDN节点拉取算法包到app</li></ul><ol start="3"><li>包大小挑战<br>由于端上资源有限,因此对新增的决策sdk包和模型文件大小以及计算资源会有一定限制。</li></ol><ul><li>方案<br>决策:对决策做最大化的瘦身,只保留一站式决策可运行的最小版本能力(如:groovy规则、特征预处理)<br>模型:控制模型的参数量级、拆分部署;基于MNN框架对模型进行压缩</li></ul><ol start="4"><li>适配挑战<br>移动端由于每个用户的设备都不一样,会有不同的适配成本。方案目前一期仅支持在基于MNN在安卓设备进行,IOS兼容方案仍在探索中</li><li>模型运行挑战<br>模型文件需要在端上运行,端侧得支持可运行模型文件的环境。</li></ol><ul><li>方案<br><img src="/img/remote/1460000043787590" alt="图片" title="图片"></li></ul><p>图3-4 基于MNN的运行结构图</p><p>基于淘宝MNN开源引擎,在端侧适配模型可运行的环境</p><h2>flink端智能</h2><h3>整体流程</h3><p><img src="/img/remote/1460000043787591" alt="图片" title="图片"></p><p>图4-1 AI平台flink端智能方案简易流程图</p><p>如图4-1所示,表示了flink端智能方案简易流程图,和2-1对比差异点如下。</p><p>1.触发逻辑</p><ul><li>原先云端的模型触发都是基于soa接口方式触发,改为flink端后,在线推理都是基于消息触发</li></ul><p>2.模型模块</p><ul><li>模型运行环境:原先模型的运行环境是在ModelService集成的,现在模型在线推理在flink,需要在flink端支持模型的运行</li><li>模型更新交互变化:原先模型的加载都是ModelService中监听模型新增或者修改,现在模型的新增和更新需要在flink端监听apollo配置变化,在flink端替换新模型</li></ul><p>3.特征模块特征</p><ul><li>存储介质变化:特征的存储原先只在云端的在线存储中,flink端智能后需要将特征存在在本地磁盘或者内存中</li><li>特征更新交互变化:原先特征的新增或者修改都是在FeatureService监听特征的变化,将特征和存储的关系维护在特征服务中,现在需要在flink端监听特征变化,且需要将特征直接拉取到flink本地磁盘或者内存中</li></ul><p>4.决策模块</p><ul><li>原先决策是作为服务给业务方调用,端智能后需要打成sdk包给flink端调用</li></ul><h3>挑战和方案</h3><p>1.模型调用本地化</p><p>模型调用由SOA方式改为本地调用。</p><ul><li>方案<br><img src="/img/remote/1460000043787592" alt="图片" title="图片"></li></ul><p>图4-2 模型调用本地化</p><p>flink会接入决策SDK,并通过tf for java将模型加载到本地,提供模型在线推理能力。模型更新后由AI管理平台更新apollo配置信息,flink端通过监听apollo方式进行模型更新。</p><p>2.特征本地化</p><p>通过将在线数据预加载到内存或者本地磁盘,进行特征的加工处理和本地调用。</p><ul><li>RocksDB方案<br><img src="/img/remote/1460000043787593" alt="图片" title="图片"></li></ul><p>图4-3 RocksDB方案</p><p>a. 特征变更</p><ul><li>特征分发系统会监听hive表特征变更,在特征分发系统将特征数据转成SST文件</li><li>特征分发系统会将SST文件上传到oss,并将配置变更通知apollo配置中心</li><li>flink端监听apollo配置变更,将sst文件拉到本地磁盘</li></ul><p>b. 特征查询<br>flink端执行在线推理会从RocksDB查询本地磁盘的数据</p><ul><li>内存存储的方案<br><img src="/img/remote/1460000043787594" alt="图片" title="图片"></li></ul><p>图4-4 内存方案</p><p>通过flink hive connector的改造,将hive的离线数据,通过Partitioned的方式,让每个taskmanager只加载部分所需的维表数据。</p><ul><li>内存存储的优化挑战</li></ul><p>a. Partition分区如何做?<br>基于用户id对数据进行预设分区</p><p>b. 内存如何优化?</p><p><img src="/img/bVc7ThR" alt="image.png" title="image.png"></p><p>图4-5 自研序列号方案</p><p>基于团队自研的fast_bytes序列化方案,可以将内存数据降低为之前的三分之一</p><h2>应用端智能</h2><p>应用端智能即在业务应用服务中,直接存储特征数据,运行模型进行在线推理。</p><h3>整体流程</h3><p><img src="/img/remote/1460000043787595" alt="图片" title="图片"></p><p>图5-1 业务应用端智能方案简易流程图</p><p>如图5-1所示,表示了应用端智能方案简易流程图,和2-1对比差异点如下。</p><p>1.模型模块</p><ul><li>模型运行环境:原先模型的运行环境是在ModelService集成的,现在模型在线推理在业务应用,需要在业务应用支持模型的运行</li><li>模型更新交互变化:原先模型的加载都是ModelService中监听模型新增或者修改,现在模型的新增和更新需要在业务应用监听apollo配置变化,在业务应用替换新模型</li></ul><p>2.特征模块</p><ul><li>特征存储介质变化:特征的存储原先只在云端的在线存储中,业务应用智能后需要将特征存在在本地磁盘</li><li>特征更新交互变化:原先特征的新增或者修改都是在FeatureService监听特征的变化,将特征和存储的关系维护在特征服务中,现在需要在业务应用监听特征变化,且需要将特征直接拉取到业务应用本地磁盘或者内存中</li></ul><p>3.决策模块</p><ul><li>原先决策是作为服务给业务方调用,端智能后需要打成sdk包给业务应用本地执行在线推理</li></ul><h3>挑战和方案</h3><ol><li>SDK设计</li></ol><p><img src="/img/remote/1460000043787596" alt="图片" title="图片"></p><p>图5-2 决策sdk设计图</p><p>如图5-2所示,展示了决策sdk的整体设计。</p><ul><li>模型服务将模型的加载,监听模型变更,优雅预热等逻辑打包成模型sdk</li><li>特征服务将特征的加载,监听特征变更等逻辑打包成特征sdk</li><li>决策在线服务将ABTest,流程编排,计算逻辑,特征预处理逻辑以及模型sdk,特征sdk逻辑打包为决策sdk,给业务应用调用</li></ul><ol start="2"><li>模型类型兼容挑战</li></ol><p>当前的业务应用如为java应用,则在运行时,只支持tf和pmml模型,python和python-gpu模型无法支持。</p><ul><li>方案<br>目前短期解决方案为将部分python模型转为tf或者pmml模型打入业务应用进行运行,长期来说AI平台会探索为各类模型提供一个统一的运行环境,或者可支持的转换工具支持各类模型在同一环境中运行</li></ul><ol start="3"><li>模型本地化</li></ol><p>模型调用由SOA方式改为本地调用。</p><ul><li>模型运行方案<br><img src="/img/bVc7Th9" alt="image.png" title="image.png"></li></ul><p>图5-3 模型调用本地化</p><p>业务应用会接入决策SDK,并通过tf for java将模型加载到本地,提供模型在线推理能力。模型更新后由AI管理平台更新apollo配置信息,业务应用通过监听apollo方式进行模型更新。</p><ul><li>模型生命周期管理</li></ul><p>方案新增或者修改模型上传到oss,通知apollo配置变更,应用服务监听配置变更,调用jni的tf接口加载模型到内存。在新老模型预热完毕后调用api接口卸载模型。</p><ul><li>模型更新方案</li></ul><p>a. 新老模型如何同步运行?<br>创建两个模型对象进行管理,支持同模型新老版本同时运行。</p><p>b. 新老版本更新的如何优雅预热?<br>基于模型历史一小时的rt均值,以及当前最近20次请求rt是否小于该均值进行判断是否预热成功。如果预热成功则新老模型切换。</p><ol start="4"><li>特征本地化挑战</li></ol><p>本地化特征存在特征数据过大,导致业务应用启动过慢,无法快速扩容等问题。</p><ul><li>方案<br>将特征加载从单线程转为多线程加载,并最大限度用上ESSD盘或NAS盘的IO上限</li></ul><ol start="5"><li>在线特征挑战</li></ol><p>在线特征如果业务应用直接调用,会存在连接池无法管控,调用量无法管控,在线存储压力过大导致互相影响等问题。</p><ul><li>方案<br><img src="/img/remote/1460000043787597" alt="图片" title="图片"></li></ul><p>图5-4 在线特征管理图</p><p>通过AI平台统一的连接池管理,核心业务在线存储隔离以及限流机制避免在线存储的压力过大,互相影响问题。</p><p>目前一站式AI平台已经能够支持在多端进行在线推理能力,但是很多细节点需要持续优化。后续一站式AI会基于当前问题对各端智能方案进行持续优化,也会基于业界主流的AI平台架构和方案,持续对AutoML、AutoFE、模型Fass化部署、分布式训练和预测进行持续的建设和演进工作。</p><p>(本文作者:柳健强)</p><p><img src="/img/bVc7Tjs" alt="image.png" title="image.png"></p>
Faas在哈啰AI平台的落地实践
https://segmentfault.com/a/1190000043689341
2023-04-18T11:42:12+08:00
2023-04-18T11:42:12+08:00
哈啰技术
https://segmentfault.com/u/hellotech
0
<h2>为什么哈啰AI平台需要Faas</h2><h3>Al平台当前的痛点</h3><p><img src="/img/remote/1460000043689343" alt="图片" title="图片"></p><p>一是运维复杂问题,AI平台有多种不同语言的模型推理服务, 如python、C++(tf-serving)、Java等,各自管理上百个不同类型的模型;架构也很复杂,存在大型单体应用、多container应用、小型GPU应用等多种服务组织方式;同时,手动运维有余,自动化工具不足。</p><p>二是稳定性问题,成百上千模型集中式部署,存在明显热点问题,在应对一些突发流量的时候,自动伸缩速度也存在问题。同时,模型cpu、gpu资源竞争问题也困扰了我们。</p><p>三是IDC成本问题,存在资源利用率低的问题,有很大的提升空间。</p><h3>Al平台对新架构的诉求</h3><p>Al平台分为在线服务域(决策、特征)和模型训练域(模型、训练),模型平台是模型训练域的一个子域。</p><p>我们希望Al平台在应对突发流量时,可以快速响应,保持稳定的服务;对于低频的模型,可以实现缩容到0;对于快速迭代的模型,可以方便进行AB灰度。同时我们希望成本可控、易于运维、易于部署。</p><h2>云原生演进与Faas选型</h2><h3>从K8s到Faas</h3><p><img src="/img/remote/1460000043689344" alt="图片" title="图片"></p><p>Faas能给我们带来极致弹性,可缩容至0;运维成本更低,带来更低的开发复杂度和更好的运维效率。这与AI平台的特点也是分不开的。模型是无状态的,生命周期短,冷启动时间短,业务需求变动快,开发周期短,流量零散而难预测,突发流量多。</p><h3>Faas技术选型</h3><p><img src="/img/remote/1460000043689345" alt="图片" title="图片"></p><p>经过调研,最终花落Knative。Knative支持多元触发,如Eventing/http/grpc触发;同时带来弹性扩缩容的能力,可以缩容到0;在AI平台能带来版本管理和流量分配的能力。</p><h2>Faas在模型平台的落地实践</h2><h3>模型平台Faas化</h3><p>模型平台Faas化具有很大的价值。一是是平台能力升级,支持大模型、GPU模型及更多模型类型;二是稳定性收益,通过热点模型隔离,避免多模型混布,来更好的应对突发流量;三是人效收益,GPU模型、大模型全程算法自助发布;四是IDC降本收益,降低模型在线服务成本。</p><h3>模型Faas部署</h3><p><img src="/img/bVc7tKh" alt="image.png" title="image.png"></p><p>我们的模型平台是一个非常完备的平台,无论是算法同学还是工程同学,都可以模型平台上方便的去上传模型,管理模型的入参出参、模型的版本。我们要兼容模型管理的能力,底层有很多异构,如python集群、gpu集群、pmml集群和TF集群等。针对这些异构,我们要用分集群的方式把它变成faas同构的框架。上面有了模型的管理平台,下面有了faas集群,中间的核心是平台路由的改造。当算法和工程同学评估了模型的QPS,可以在平台上勾选faas的一键部署,就能方便的部署到faas集群里,这样就能降低运维成本。</p><h3>模型自动压测&规格标准化</h3><p><img src="/img/remote/1460000043689346" alt="图片" title="图片"></p><p>Faas部署很大程度上依赖服务本身的资源设置&弹性伸缩设置,适当的设置将极大的减少启动时间、平滑弹性伸缩、最大程度节省资源。我们与压测平台合作,打造自动压测能力,评估模型Pod资源和规格标准化,再调用云原生Faas接口进行Faas部署。</p><h3>Faas冷启动优化</h3><p><img src="/img/remote/1460000043689347" alt="图片" title="图片"></p><p>Faas通用的痛点是冷启动速度,我们在思考模型的启动,能否有继续提速的空间。于是就有了模型分发服务,它可以把一些模型资源预下载下来,从原来的150毫秒降低到10毫秒左右的单模型的启动。</p><h3>Faas模型优雅预热</h3><p><img src="/img/bVc7tKk" alt="image.png" title="image.png"></p><p>深度大模型存在预热不充分导致RT突增问题,我们基于Knative的版本管理、流量分配、蓝绿部署等能力,结合自研GraySDK提供了优雅解决方案。</p><h3>案例:哈啰智能调度Faas改造</h3><p><img src="/img/remote/1460000043689348" alt="图片" title="图片"></p><p>智能调度是是两轮领域的核心场景之一。我们每次去做调度的时候,会进行调度收益的核算,用调入收益减去调出损失,再减去调度成本。业务的峰谷波动明显,计算量大,并且每个城市用的模型不一样,模型非常多,适合Faas的落地。这里我们做了定时预测的Faas化,特征能力的Faas化和模型能力的Faas化。通过效果回收,我们发现IDC成本下降了35%,整体性能上升了20%。</p><p>我们的调度业务通过无感切换到 Serverless,有效利用 Serverless 免运维、强隔离、按量计费的特性,既实现了得集群不用再为定时任务预留机器资源,同时在高峰期可以迅速大量扩容,提高了系统计算能力,让业务的稳定性也有了很大的提升。</p><h2>Faas与AI平台的未来展望</h2><h3>Faas在更多应用场景落地</h3><p>一是特征平台Faas化,特征的冷热分布十分不均匀,当热点特征高峰期时需要整个服务扩容,存在资源浪费、扩容速度慢、资源抢占等风险。二是内部管理后台,很多后台每天只有个别时段会有运营用户使用,但机器却7*24小时提供服务,可以用Faas的按需分配、缩容到0来提高资源利用率。三是定时能力,定时预测能力在某些时间点存在突增流量,且QPS能打到非常高,如果服务维度部署下,存在空闲期资源的极大浪费。</p><h3>Faas在更多业务领域落地</h3><p>一是智能客服——聊天机器,智能客服业务存在很多突发流量,比如用户进入客服问答的随机性很大,当舆情来临时的客服流量激增,也非常适合Faas解决方案。二是智能营销——大促等突发流量,互联网业务的发展离不开智能化营销手段,以电商为例,往往半月一小促,一月一大促,需要更灵活的资源调度方式支持营销业务发展。三是IoT传感器信息处理——各种语音精灵,IOT交互设备绝大部分时间都处于待唤醒状态,结合Faas缩容到0且能快速扩容的能力可以大幅提高资源利用率。</p><p><img src="https://segmentfault.com/img/bVc0Nn0" alt="图片" title="图片"></p>
Elasticsearch 整合机器学习强化排序
https://segmentfault.com/a/1190000043650417
2023-04-12T10:46:49+08:00
2023-04-12T10:46:49+08:00
哈啰技术
https://segmentfault.com/u/hellotech
0
<p>本文介绍如何将机器学习预测能力迁移至es内部,增强排序能力, 构建一个高性能、分布式搜排一体系统,并通过落地更多复杂模型特征和更深的计算,为业务带来新的增长点,我们将LR->树模型完成全量排序,给核心业务带来1.2%的ab增长。</p><h2>背景介绍</h2><p>我们团队主要负责哈啰四轮司乘匹配的召回排序,在顺风车司乘匹配场景中,司机发单后系统会从订单池中筛选展示合适的乘客订单,促进司机发单到完单,带来营收。整个过程排序是一个非常关键的环节,目前我们底层的排序架构是用了经典粗排-精排重排级联排序。</p><p><img src="/img/remote/1460000043650419" alt="图片" title="图片"></p><p>在粗排阶段,我们使用LR(Logistic Regression)算法对数千个订单进行排序。而在精排阶段,我们筛选出前300名订单,并使用rankservice完成深度排序。在重排阶段,我们再选取10个订单进入业务相关重排。</p><p>我们团队在精排阶段将模型由树模型升级成深度模型,并取得了不错的业务效果,同时也沉淀了一定的技术。因此我们开始考虑将粗排也进行精排化,即采取更加复杂的模型和更多的特征。参考行业经验以及业务场景的特点,如路线匹配路线和算法离线评估,我们决定将粗排从LR升级为效果更好的树模型。</p><p>在达成这个目标的同时,也会解决历史中存在的技术问题。之前的树模型受限于技术,单机只能支持300排序 -> 升级到es内部完成全排序。LR的迭代全部手写代码->编程配置化,加速迭代,增加稳定性。</p><h2>整体方案</h2><h3>机器学习在线预测流程</h3><p><img src="/img/remote/1460000043650420" alt="图片" title="图片"></p><p>需要在程序中获取一批特征inputs传入模型,返回模型预测分数output。在这个过程中,根据来源分成以下几种类型:</p><ul><li>实时特征</li><li>上下文特征 </li><li>离线特征 </li><li>组合特征</li></ul><p>每种类型特征都需要对应一套技术解决方案。</p><h3>具体方案</h3><p>我们将整个机器学习以插件的形式嵌入es内部,其中包含多个重要的组件。<br><img src="/img/remote/1460000043650421" alt="图片" title="图片"></p><p>1.特征获取方案</p><ul><li>实时特征<br>司机的实时特征由flink计算后上游查询从接口传入,订单的实时特征由flink写入索引。</li><li>上下文特征<br>司机的实时特征由调用传参带入。</li><li>离线特征<br>由kkv系统完成离线特征的加载、查询、更新,可以支持到分钟级别。</li><li>组合特征<br>我们内部设计了一套DSL,通过配置即可完成特征生成,包含了组合特征。</li></ul><p>2.重要组件简介</p><ul><li>kkv:<br>在线获取离线特征。</li><li>热加载:<br>es脚本插件需要重启整个集群才能完成更新,我们在它的底层接口进行了一层抽象,借助热加载的能力完成对业务插件的更新。</li><li>执行引擎:<br>执行引擎主要用来对模型的加载、预测,底层支持多种算法模型预测。</li><li>文件分发系统:<br>主要用于将文件更新到整个集群,触发业务回调,算法同学在训练平台玩完成kv训练,模型训练以及配置文件设置,会通过文件分发系统实时同步到整个es集群,完成更新,触发业务回调。</li><li>特征生成:<br>特征配置生成系统,通过自定义DSL获取全部的模型入参特征。</li><li>DEBUG:<br>用于快速验证在es内部机器学习模型预测的准确性。</li></ul><h2>关键组件</h2><h3>执行引擎</h3><p>执行引擎主要用于对模型的加载预测。</p><p><img src="/img/bVc7jy2" alt="image.png" title="image.png"></p><p>算法训练常见的模型有树模型、深度模型,还有部分自定义。不同的模型框架,算法可能需要工程做单独的适配,我们期望有一种通用的执行引擎可以解决掉复杂的适配问题,既可以支持在sparkml 的模型,也可以支持深度学习。我们选择了mleap,它是一种通用的执行引擎,算法同学使用sparkML完成模型的训练,使用mleap进行序列化,生成统一的模型,在线上使用mleap的api 进行加载预测。</p><h3>kkv系统</h3><p>主要用于离线特征的加载和查询,我们会面对一些海量离线特征存储查询的挑战。</p><p>挑战:</p><ul><li>检索rt要求高<br>举个例子,假设本次检索命中了1000个订单,模型有100个离线特征,单位时间kv检索到达10w次。远程io无法支持,所以做成特征本地化。</li><li>特征量大<br>我们的特征已经到达百亿级别,传统的加载无法支持,这块我们的解决方案是使用mmap内存映射技术,读取二进制实时反序列化,这个解决方案es的docvalue 底层是一样的。</li></ul><p>我们调研了一些常见mmap解决实现, ohc mapdb rocksdb paldb , 发现paldb在性能、存储和索引速度都是最快的。</p><p>线上数据:<br><img src="/img/remote/1460000043650422" alt="图片" title="图片"></p><p>(数据均来自精排的深度学习)</p><p>hive表 50G -> 索引文件 20G -> 映射内存 10G</p><p>查询速率:获取390订单,100纬度离线100组合50上下文的特征数据,耗时5.6ms。</p><p>(总rt 18.5 tf预测: 12.5 特征: 6)</p><h3>文件分发系统</h3><p>dragonfly主要用于更新文件触发业务回调。</p><p><img src="/img/bVc7jzg" alt="image.png" title="image.png"></p><p>我们有配置文件 jar 模型 kv 文件,需要被分发到es内部,触发回调。我们针对共性的需求开发了一个分发系统。</p><p><img src="/img/remote/1460000043650423" alt="图片" title="图片"></p><p>逻辑很简单,文件通过dragonlfy 上传到存储系统中(OSS,HDFS等),修改meta,consumer 监听远程文件,发现meta变更自动下载文件->校验md5->触发回调用。</p><p>功能:</p><ul><li>文件变更自动下载最新文件,触发业务回调;</li><li>极速MD5校验本地记录MD5;</li><li>易用性,支持注解驱动;</li><li>支持灰度加载,可与任意配置中心整合 apollo,自定义配置;</li><li>更新回调状态,用于监控;</li><li>支持多环境,采用虚拟环境;</li><li>支持多回调等等。</li></ul><p>以下为rankservice与spring整合的截图。</p><pre><code> //文件监听
@Dragonfly(storagePath = "model/lo_cc_deep_model_v1.tar.gz")
public void getDeepFMModel(File file, String path) throws IOException {
super.getDeepFMModel(file,path);
}
//多回调
@Dragonfly(storagePath = "model/lo_cc_deep_model_v1.tar.gz")
public void test(File file, String path) throws IOException {
System.out.println("单体测试3");
}
//目录监听
@Dragonfly(dirPathMonitor = "model/deep",filterBean = ApolloFilter.class )
public void getDeepFMModel(File file, String path) throws IOException {
super.getDeepFMModel(file, path);
}</code></pre><h3>热加载</h3><p>不需要重启整个集群即可完成插件更新的功能。</p><p>es启动的时候就会进行热加载插件的加载,通过dragonfly监听/回调业务.jar,装载实现进入插件库,es query 中指定相关的实现即可完成对业务执行。</p><p>jar里面包含了多种类型插件实现:</p><ul><li>filter实现:eta过滤,夹角过滤,沿途距离过滤等</li><li>sort实现:排序有顺路度,mleap排序(树模型排序,tf排序)</li><li>script_field实现:字段插件有顺路度</li></ul><p>插件开发tips:</p><ul><li>是否存在外部资源<br>需要手动关闭</li><li>是否存在第三方jar,存在内存泄漏<br>热加载常见问题内存泄漏,可以通过压测来发现</li><li>提前加载预热,防止突刺<br>对class提前初始化,存在资源加载的情况<br>模型提前预热</li><li>分层热加载<br>轻资源 class的加载和卸载<br>重资源独立,不参与热加载,比如 kv 热加载会导致之前的kv pagecache淘汰,重新reload,会消耗系统资源</li><li>错误日志限制输出<br>es 文档计算是row by row,有多少文档输出多少次日志,严重消耗系统cpu,导致服务不稳定<br>限制一次请求只能输出一个错误日志</li></ul><h3>配置化的迭代</h3><p>我们期望特征迭代配置化,算法工程同学不写一行代码。</p><p>难点:特征组合(特征交叉),特征处理的逻辑千变万化,需要设计一套方案来解决特征灵活变换的问题。</p><p>介绍:如何获取一个组合特征<br><img src="/img/remote/1460000043650424" alt="图片" title="图片"></p><p>如图所示, 原始user 特征+原始item特征,经过特征组合或者交叉,生成模型特征。</p><p><img src="/img/remote/1460000043650425" alt="图片" title="图片"></p><p>我们看下之前的解决方案:每个特征都需要手写代码,一个模型有几百个特征,非常麻烦,并且容易出错。我们的解决方案:使用自定义算子+原始特征,利用反射来完成特征生成。</p><p><img src="/img/remote/1460000043650426" alt="图片" title="图片"></p><p>我们内部自研了一套dsl,通过配置就完成模型任意类型特征的生成。(实时 kv 上下文 组合)</p><p><img src="/img/bVc7jzZ" alt="image.png" title="image.png"></p><p>我们会将验证准确的特征配置录入到特征表中,算法同学在录入模型特征的时候自动出提示,保证录入的准确性与效率,如果是模型微调v2版本,直接复制v1 修改几个特征即可。整个算法特征被管理起来,保证同组使用的唯一性。</p><h3>DEBUG</h3><p>在ES内部快速完成机器学习debug。</p><p>解决方案:<br>ES内部有一个API<br>org.elasticsearch.script.ScoreScript#execute(ExplanationHolderexplanation explanation)<br>我们将计算每个item预测过程进行对外输出。</p><p>比如以下例子:包含了策略名称,请求入参数,模型入参数</p><pre><code>explanation.set(String.format("mleap(policyName=%s,params=%s,detail=%s)", this.sortPolicy, params,tempMap));</code></pre><p>如图所示:可以对匹配到的订单进行debug</p><p><img src="/img/remote/1460000043650427" alt="图片" title="图片"></p><p>可以获得:原始user特征,原始item特征,模型入参特征详细信息,这样就可以对模型的准确进行校验。</p><h2>稳定性</h2><h3>完善压测方案</h3><p><img src="/img/remote/1460000043650428" alt="图片" title="图片"></p><p>每次新上线模型前除了常规的fat、uat环境测试,上了pre环境会进行压测回归&新功能验证。</p><h3>针对存在的风险点进行极限压测</h3><p>涉及变更点:业务插件,模型,特征;</p><p>要求:新老插件交替,模型特征重置,保证不出现抖动,在任意变更点保证服务都是稳定的;</p><p>方案:天级别变更为分钟级别变更;</p><p>结果:经过一周的压测,服务整体稳定,没有出现内存泄露,基本判断方案成了。</p><h3>灰度变更</h3><p>稳定性三板斧:可灰度,可监控,可回滚。由于新上模型,需要做变更灰度。我们借助dragonfly顺序加载&灰度来完成。</p><p><img src="/img/bVc7jAv" alt="image.png" title="image.png"></p><p>dragonfly优先监听加载灰度文件,针对灰度文件配置加载其他文件。</p><p>如上图所示:</p><ul><li>模型1:允许2台机器加载</li><li>模型2:只允许节点为ES1进行加载</li><li>jar:允许两台机器加载</li></ul><h3>机器学习分组加载</h3><p>各个业务的模型不一样,需要的特征也不一样,我们期望针对每一个模型进行分组加载,而不是让每台机器全量加载。这里我们借助的是es的分组特性和index的模板。</p><p><img src="/img/remote/1460000043650429" alt="图片" title="图片"></p><p>我们在创建index的时候会直接绑定这个index在哪些机器上生成,如index1在group1上,index2在group2上,index3在group3上,通过文件分发系统来完成对整个机器学习的分组加载。同时,我们也可以通过对每个业务模型特征,进行分组加载来减少不必要的开销。例如,某些业务可能只需要部分特征进行训练,kkv系统支持按需进行索引部分字段提供线上查询,这样就可以减少特征的维度,从而降低了计算成本,提升了系统性能。</p><h2>模型预测加速</h2><p><img src="/img/remote/1460000043650430" alt="图片" title="图片"></p><p>我们从三个维度进行机器学习的性能加速。首先是请求缓存,第一个是user特征的缓存,计算一次后可重复使用。第二个是对象复用,由于es的计算是row by row的计算,我们计算完一个row后,它的模型入参inputs可以继续复用,下一个计算开始的时候直接对item维度特征修改即可。</p><p>其次是模型缓存,即模型预测加速。我们固定了输入/输出的schema,并预分配足够的内存空间用于存储所有的结果数据,从而避免了多余的计算和内存空间动态分配的开销。同时,我们也采用了模型入参减少key的输入优化方式,进一步缩短了计算时间。</p><p>接下来是全局缓存,我们通过mmap内存映射技术做整个kv的加速,还有一些做特征加速。我们会有一些高频的特征,比如某个特征的均值、方差、最大值、最小值等,具有量小、高频访问特性,所以我们可以把它长驻堆内。</p><h2>上线后业务上的表现</h2><p>1.支持spark 全部的模型;<br>2.模型迭代,免开发,通过特征配置化、热加载、压测、灰度可以快速稳定上线;<br>3.算法插件组件化、可插拔、灵活编排和支持多轮排序;</p><p><img src="/img/remote/1460000043650431" alt="图片" title="图片"></p><p>这里是一个常见的例子,es召回完成之后,直接进行级联排序,模型B进行score,模型C进行rescore。其次是灵活编排,我们整个模型库可能有ABCDEF的模型,假设在第一阶段有10000个订单,我们使用模型ABC同时进行排序,排序后组合取Top1000进行模型D排序,排序后取300个进行模型E排序,整个过程非常灵活。</p><p>4.热加载,特征模型 jar实时更新,无抖动;<br>5.火焰图,单核心场景,排序只占到7%的cpu消耗;<br>6.在单机单分片场景 1500深度下, 树模型相比LR 多了10ms,全场景 LR -> 树模型,顺风车核心ab 增加 1.2%。</p><h2>后续动作</h2><p>短期目标:后续我们计划会补齐es的排序短板,支持深度学习。</p><p>目前主要的问题在于es的计算是row by row的,没有办法使用TF的batch计算,每一次计算TF都要开启session,这是非常耗资源的。这块未来会通过es rescore插件来解决。</p><p><img src="/img/remote/1460000043650432" alt="图片" title="图片"></p><p>同时我们会去整合openvino,目前在业内有很多机器学习框架,如Tensorflow、飞桨、Pytorch、Caffe等,算法又有这方面的诉求,需要工程同学去做每一个框架适配,我们在思考有没有统一的解决方案,可以将主流的DL框架收拢起来,使用一个API就能完成预测。我们发现可以使用openvino来解决这个问题。目前已经在做相关的技术调研,预计下半年可以上到我们的rankservice中,稳定半年以上就会开始迁移到ES。</p><p>我们最终期望实现的目标是将所有排序都在es内部完成,可以达到一排到底,做到极致的性能及高度的灵活。</p><h2>问题:排序系统为什不单独做?</h2><p>我们选择将排序系统与搜索系统结合在一起,主要是为了在有限的资源内兼顾性能和业务效果。</p><p>这样做的优势包括:</p><ul><li>性能强大:我们将搜排系统融合在一起,使得排序系统具有极强的性能。</li><li>开发、维护和运维成本低:与单独构建排序系统相比,我们不需要关注数据质量、存储、分片、查询mapreduce等问题,因为搜索系统已经提供了这些功能,因此我们所需关注的就是排序方法,这使得我们的排序系统更加灵活并易于维护,相当于了实现Serverless运行。</li><li>Lucene的完善支持:我们的排序系统使用Lucene作为基础,并利用其丰富的功能(例如function_score、score、rescore和debug),这使得我们可以进行灵活编排和支持多轮排序的操作。</li><li>稳定&效率:我们的排序系统使用特征配置化的方式来进行开发,通过标准化的上线流程、压测、灰度和分组等一系列措施来保证其稳定性,这可以使得我们的系统更加易于管理和调试。</li></ul><p>综上所述,将排序系统和搜索系统结合在一起,可以实现性能优秀、维护成本低、功能丰富、操作灵活和稳定性高的排序系统,这正是我们所需要的。</p><p>(本文作者:彭晟)</p><p><img src="https://segmentfault.com/img/bVc0Nn0" alt="图片" title="图片"></p>
Flutter技术在哈啰两轮的升级之路
https://segmentfault.com/a/1190000043623038
2023-04-04T12:02:39+08:00
2023-04-04T12:02:39+08:00
哈啰技术
https://segmentfault.com/u/hellotech
0
<p>上一篇,我们分享了<a href="https://segmentfault.com/a/1190000043540587">Flutter在两轮的应用推广</a>,本次分享的主题是Flutter在两轮的升级之路,主要分为两部分。一是我们在Flutter落地之后,由于业务的发展,导致我们需要对Flutter进行升级。二是升级之后我们遇到了一些问题,这里列举了一个比较典型的案例——FlutterEngine的自定义。</p><h2>背景介绍</h2><h3>升级原因</h3><p>两轮从2019年来开始使用flutter,算是应用比较早的团队。随后flutter升级了2.x版本,这个版本有个比较重要的更新就是空安全(nullsafety),因为在1.x的版本,如果一个对象为null之后,再去调用它的任何方法,都会抛异常,在页面上的表现就是页面假死,也不会再刷新了。所以就需要研发人员关注下自己使用变量时是否已经为空了,2.0会强加这个检查,在编译阶段让我们确定好次变量是否可空以及是否懒加载,这样在我们使用变量时,就会有系统提醒我们,相当于帮我们做了这个工作,减少出错率。其实很多其他语言也都有这样的检测机制。再加上一些性能的提优,于是有了升级的诉求。</p><h3>Flutter版本敲定</h3><p><img src="/img/remote/1460000043623041" alt="图片" title="图片"></p><p>这里对比了一下1.x版本和2.x版本的主要区别。2.x版本优化了应用启动的延迟,调用Dart VM的GC策略也做了一些改进,低端Android设备的初始帧出线间隔时间最多减少了约300ms。</p><p>此外,空对象消息是我们在开发中最常见的exception,所以空安全是很有必要的。pub.dev已经发布了超过1000个空安全package,包括来自Dart、Flutter、Firebase和Material 团队发布的数百个package。</p><h2>升级实施</h2><h3>基础设施</h3><p>基础设施升级主要涉及到两方面,打包机sdk升级和个人研发侧版本管理工具。由于SDK升级在2.8.1版本删除了一些被弃用的Api,会造成我们编译报错。可参考以下修改:</p><ul><li>切换本地环境为2.8.1版本</li><li>运行工程查看报错</li><li>修改对应报错库的api</li><li>本地运行成功后,移动端CI/CD系统发库提测</li><li>测试同学全量回归,灰度</li></ul><h3>升级节奏</h3><p>整体的升级节奏包括:基础升级启动、各个业务线研发启动、业务线null_safety改造1-n次、全量回归、灰度发布和异常观测。</p><p>本次2.0升级工作量基本都在null_safety语法改造上,由于工作量大涉及范围广所以无法在短期内完成语法改造。所以本次改造将依照业务功能划分,尽量收拢每次语法改造带来的影响范围,从而引起测试侧多次全量测试的人力浪费。</p><h2>Flutter空安全</h2><p>健全的空安全已在 Dart 2.12 和 Flutter 2.0 及更高版本中可用。Dart 3 及以后的版本将只支持健全的空安全。</p><p>有了空安全,下面代码中所有的变量都是非空的:<br><img src="/img/bVc7coL" alt="image.png" title="image.png"></p><h3>依赖项检查</h3><p>使用官方工具进行类型推断,最好确保依赖的所有包都已经升级为null safe版本。所以在改造时应该从底层向上逐步升级。</p><p>在控制台执行如下命令:<br><code>dart pub outdated —mode=null-safety</code><br>可以查看到当前项目工程依赖包是否是null safe。</p><p>对于其中不符合要求的内部库,需要手动升级后打包,生成null safe版本,建议版本号比之前最新版增加一个大版本;对于三方库可以在三方库官方站点或者中文站点查找符合null safe的最新版本号,并修改项目工程的yaml文件。</p><p>全部依赖修改完成后,执行命令拉取最新依赖:</p><pre><code>flutter pub get
flutter pub upgrade</code></pre><p>操作完成之后可以使用第一步的命令检查是否依赖都校正完了。</p><p>如果提示所有依赖都声明支持null safe,或者期望跳过依赖检查,就可以进行后续操作。</p><p>执行成功之后,会生成url,点击可在浏览器中使用迁移工具:</p><p>左侧是需要迁移的文件目录,官方工具会对选中文件进行类型推断并自动添加如下标记:</p><ul><li>! (类型不可为空)</li><li>? (类型可为空)</li><li>late (初始化延时)</li><li>late final (一次性初始化延时)</li><li>required (参数标记)</li></ul><p>面板中间可以看到详细改动,右侧可以看到当前文件改动的原因。</p><p>对于不需要迁移或者大型文件需要分步迁移的场景,可以只勾选需要迁移的文件目录,官方迁移工具会自动为不需要迁移的文件顶层添加null unsafe Dart的版本注释。</p><h3>引入支持null safe的第三方库</h3><p>在更改本地代码时,一般都需要先升级依赖的第三方库,但是在官网第三方库下载地址<a href="https://link.segmentfault.com/?enc=CUj79DzieP5VFQ%2BFswa5XA%3D%3D.4NQ6Vql9kakmMb0GRsuiR1QdpolFGis90qPagnbQahs%3D" rel="nofollow">https://pub.flutter-io.cn</a>或者中文站点<a href="https://link.segmentfault.com/?enc=8jVjELatjufstCooq5f4ig%3D%3D.l1seQKwBaPODOTvL%2BrepEIGfXFYjGYquQWgu5NkH9tw%3D" rel="nofollow">https://pub.flutter-io.cn</a>中查询到依赖库的最新版本。<br><img src="/img/bVc7ctV" alt="image.png" title="image.png"></p><h3>常见错误及修改方式</h3><ul><li>错误1:The non-nullable variable 'preferences' must be initialized.</li></ul><p>原因:非空的静态变量或者顶层变量在声明时没有初始化,诊断器就会报此错误。因为flutter对于没有初始化的变量会自动赋值为null,而该变量并没有声明可空。</p><p>解决方法:<br><img src="/img/remote/1460000043623042" alt="图片" title="图片"></p><ul><li>错误2:The method '<em>*</em>' can't be unconditionally invoked because the receiver can be 'null'.</li></ul><p>原因:可能为空的变量直接使用'.'调用方法,诊断器就会报此错误。</p><p>解决方法:<br><img src="/img/remote/1460000043623043" alt="图片" title="图片"></p><ul><li>错误3:Map<int, list> 复杂类型修改</li></ul><p>解决方法:<br><img src="/img/remote/1460000043623044" alt="图片" title="图片"></p><ul><li>错误4:The default 'List' constructor isn't available when null safety is enabled.</li></ul><p>解决方法:<br><img src="/img/bVc7cud" alt="image.png" title="image.png"></p><h2>FlutterEngine的Crash问题</h2><p>在上线之后,平稳运行一段时间,我们也持续观察监控数据,发现在native有大量新类型crash。</p><p>[FlutterEngine destroyContext]相关的crash</p><p>崩溃原因是在用户关闭应用的时候会通知到flutter引擎进行重置操作(调用方法看【FlutterEngine destroyContext】)。重置的时候由于官方问题导致 engine被多次重置销毁。在第二次的重置的时候(OnPlatformViewSetSemanticsEnabled这个方法里边获取引擎指针的时候崩溃了)</p><p>发现问题后,我们去翻看了官方issue,发现官方已经修复该问题,不过是在后续版本上修复的,结合我们自身情况(此crash发生是在后台)我们内部研讨了几种解决方案:</p><ul><li>解决方案 1</li></ul><p>官方已经修复了该问题,并已经合并代码到master。</p><p>修复的pull request:<a href="https://link.segmentfault.com/?enc=d29Kr7aNxTS25TsB9PDy9w%3D%3D.SpdL1%2FuOXUSsXty9ABbWXPvpn6edW%2BIi3uR%2FJabXxtoMqs%2F%2F34S2G9BFo1nY7XUHwCnm%2BwZF4kF3QdmETtxT1A%3D%3D" rel="nofollow">https://github.com/flutter/engine/pull/30835/commits</a></p><p>bufix的描述:<br><a href="https://link.segmentfault.com/?enc=S7fP6iw1Vs%2FYJtyt4F%2FBjw%3D%3D.Qx7j9NhCbj2WiICRmUgVlzDyNOSrb8loycGCV3QCRzDLn0Wn151JzznyWFFCZnJG" rel="nofollow">https://github.com/flutter/flutter/issues/95844</a></p><p>合并到引擎commit记录:<a href="https://link.segmentfault.com/?enc=TBbR%2FPl1%2F79hZQvJdsKiHQ%3D%3D.n3Qg9Vl9fNqigmkFZ7Z8%2BVcXiOCo43Lp%2BJeyb6UDWuExK6fmB66bw0BDCdlAmBLxv6ZHJFBYJOuAS%2BRv%2FvrdKFW5muyprpzPCBNII03FQegKSsZGSEs2gWUT631DDi%2Bn" rel="nofollow">https://github.com/flutter/engine/commit/fb3ee7f2b5e6e537a2a83c9fe2cf733cd9c6ec06</a></p><p>在高版本,2.10.2(2.10.3+)以上此类问题已修复。</p><p>2.8.1Cherrypicks:<br><a href="https://link.segmentfault.com/?enc=KZtNifYyFXqgq7bXzYWkXw%3D%3D.rOnY%2BANxVRwkDI5E%2BaJA2o9GgM%2FaJ2x%2FRUxwI7awIUfskwIf7posUySdRqp7OnVD" rel="nofollow">https://github.com/flutter/engine/pull/30355</a></p><p>但目前无法直接升级到flutter版本到2.10.2 以上,暂时的解决办法是本地cherry pick 代码,编译打包flutter engine,替换2.8.1的flutter engine。</p><p>风险最小但是需要评估改pull request的cherry pick成本,需要基础技术支持打包方案文档,需要探索引擎打包融合到flutter修改打包,再到flux打flutter包流程融合。</p><ul><li>解决方案 2</li></ul><p>flutter引擎从2.8.1升级到2.10.4</p><ul><li>解决方案 3</li></ul><p>可以hook destroyContext 在判断应用状态能否调用destroyContext,但这个有一定风险可能会影响其他的逻辑。</p><ul><li>解决方案 4</li></ul><p>梳理引擎切换的场景,通过业务上避免减小crash发生。</p><p>最终我们选择了保守的方案一定制FlutterEngine。<br><img src="/img/bVc7cu1" alt="image.png" title="image.png"></p><h3>环境准备</h3><p>git<br>Python<br>Xcode<br>depot_tools创建目录 /Users/xx/Desktop/flutter-engine<br>depot_tools安装(需要全局VPN否则很慢)</p><pre><code>cd /Users/xx/Desktop/flutter-engine
git clone https://chromium.googlesource.com/chromium/tools/depot_tools.git</code></pre><p>配置环境 (~/.zshrc或者~/.bashrc)</p><pre><code>export PATH=/Users/xx/Desktop/flutter-engine/depot_tools:$PATH</code></pre><p><img src="/img/remote/1460000043623045" alt="图片" title="图片"></p><p>这里有个配置文件需要注意下:<br>gclient <br>配置在/Users/xxx/Desktop/flutter-engine目录engine文件夹创建.gclient文件</p><h3>同步依赖</h3><pre><code>cd /Users/xxx/Desktop/flutter-engine/engine
gclient sync</code></pre><h3>编译产物</h3><pre><code>cd /Users/xxx/Desktop/flutter-engine/engine/src
# --simulator就是模拟器
# --ios-cpu=arm就是armv7,不指定默认就是arm64
flutter/tools/gn --ios —unoptimized</code></pre><p>在src/out/ios_debug_unopt目录下,会生成一个 Xcode 项目,用于debug时候使用引擎源代码。</p><p>src/out/host_debug_unopt,我在debug引擎时候发现需要里面的dart-sdk,所以这个也需要编译一下。如果没有修改dart层代码就不需要执行这一步。<br><img src="/img/remote/1460000043623046" alt="图片" title="图片"></p><p>执行ninja,生成framework产物。</p><pre><code>ninja -C out/ios_debug_unopt && ninja -C out/host_debug_unopt</code></pre><p>编译过程很漫长,可以在ninja后面加上参数-j 2,避免编译占用过多的电脑资源,影响你开发。</p><p>编译完成之后,就要创建最后的Flutter.xcframework。</p><p>官方也有提供python脚本工具,src/flutter/sky/tools目录下的:create_ios_framework.py和create_macos_gen_snapshots.py</p><p>创建Flutter.xcframework:release版本加--dsym参数会生成dysm符号表</p><pre><code>cd ~/Desktop/flutter-engine/engine/src/flutter/sky/tools
# debug版本Flutter.xcframework
python create_ios_framework.py --dst ~/Desktop/flutter-engine/engine/src/out/vd/ios --arm64-out-dir ~/Desktop/flutter-engine/engine/src/out/ios_debug --armv7-out-dir ~/Desktop/flutter-engine/engine/src/out/ios_debug_arm --simulator-out-dir ~/Desktop/flutter-engine/engine/src/out/ios_debug_sim
# profile版本Flutter.xcframework
python create_ios_framework.py --dst ~/Desktop/flutter-engine/engine/src/out/vd/ios-profile --arm64-out-dir ~/Desktop/flutter-engine/engine/src/out/ios_profile --armv7-out-dir ~/Desktop/flutter-engine/engine/src/out/ios_profile_arm --simulator-out-dir ~/Desktop/flutter-engine/engine/src/out/ios_debug_sim
# release版本Flutter.xcframework
python create_ios_framework.py --dst ~/Desktop/flutter-engine/engine/src/out/vd/ios-release --arm64-out-dir ~/Desktop/flutter-engine/engine/src/out/ios_release --armv7-out-dir ~/Desktop/flutter-engine/engine/src/out/ios_release_arm --simulator-out-dir ~/Desktop/flutter-engine/engine/src/out/ios_debug_sim --dsym
# 每个版本都加上了模拟器的部分,便于使用方模拟器调试</code></pre><h3>创建gen_snapshots</h3><pre><code>cd ~/Desktop/flutter-engine/engine/src/flutter/sky/tools
# debug版本gen_snapshot_arm64和gen_snapshot_armv7
python create_macos_gen_snapshots.py --dst ~/Desktop/flutter-engine/engine/src/out/vd/ios --arm64-out-dir ~/Desktop/flutter-engine/engine/src/out/ios_debug --armv7-out-dir ~/Desktop/flutter-engine/engine/src/out/ios_debug_arm
# profile版本gen_snapshot_arm64和gen_snapshot_armv7
python create_macos_gen_snapshots.py --dst ~/Desktop/flutter-engine/engine/src/out/vd/ios-profile --arm64-out-dir ~/Desktop/flutter-engine/engine/src/out/ios_profile --armv7-out-dir ~/Desktop/flutter-engine/engine/src/out/ios_profile_arm
# release版本gen_snapshot_arm64和gen_snapshot_armv7
python create_macos_gen_snapshots.py --dst ~/Desktop/flutter-engine/engine/src/out/vd/ios-release --arm64-out-dir ~/Desktop/flutter-engine/engine/src/out/ios_release --armv7-out-dir ~/Desktop/flutter-engine/engine/src/out/ios_release_arm
# 模拟器是JIT的,不需要gen_snapshot</code></pre><p><img src="/img/remote/1460000043623047" alt="图片" title="图片"></p><p><img src="/img/remote/1460000043623048" alt="图片" title="图片"></p><h2>未来展望</h2><p>俗话说,工欲善其事必先利其器。目前两轮Flutter升级后平稳运行,对业务迭代快速发展保驾护航。未来,两轮在大前端甚至可以继续探索flutter的新特效,如flutter-web,为大前端在小程序,taro,h5,app的大融合夯实基础。</p><p>(本文作者:叶旸)</p><p><img src="https://segmentfault.com/img/bVc0Nn0" alt="图片" title="图片"></p>
哈啰基于轨迹与端智能的还车体验优化
https://segmentfault.com/a/1190000043566169
2023-03-21T16:21:52+08:00
2023-03-21T16:21:52+08:00
哈啰技术
https://segmentfault.com/u/hellotech
0
<h2>还车背景</h2><h3>还车流程</h3><p><img src="/img/remote/1460000043566171" alt="图片" title="图片"></p><p>在定点还车的模式下,用户还车需要在一些指定的区域里。此时用户停好车后在APP或小程序内点击“我要还车”,手机会将位置信息传输给后端,系统会判断是否在站点内,如在站点内会提示用户点击“确认关锁”,用户手动关闭车锁完成还车。</p><p><img src="/img/remote/1460000043566172" alt="图片" title="图片"></p><p>如果用户未授权位置信息,系统获取不到用户手机位置,会提示用户车辆不在站点内,此时用户再次点击还车,我们会给车辆下发位置,如果获取到车辆在站点内,则提示用户可以关锁还车。如果仍然无法获取到车辆位置,会影响到用户的还车。</p><h3>还车问题</h3><p><img src="/img/remote/1460000043566173" alt="图片" title="图片"></p><p>还车当前的问题主要包括用户还车体验不佳和风险系数高,用户会觉得还车速度慢、流程繁琐以及乱收费,而某些用户手机位置也存在位置滞后、漂移等情况,个别用户可能会修改手机位置,给G端、B端运营带来困扰。产生问题的原因有位置问题、判责死板繁琐、定位差异区域、关锁在订单前等方面。</p><h3>还车目标</h3><p><img src="/img/remote/1460000043566174" alt="图片" title="图片"></p><p>我们希望用户还车既快又准,制定了一些指标,包括NPS贬损、一次还车成功率、还车时长、还车定位精度、服务求助率和判责准确率等。</p><h3>解题抓手</h3><p>我们通过对定位情况进行分析,发现用户位置存在判责不准确的风险,而车辆位置相对用户位置更加准确,但由于需要下发指令重新定位并返回,会增加还车时长。可以利用的信息包括订单中持续上报的车辆位置、车身中的智能件——加速计和通过站点治理解决部分区域定向漂移。</p><p>基于此,我们主要的解题方法包括两个部分。一是预测还车行为,提前下发定位和提前进行判责,判断结果可推送到手机;二是利用轨迹预测还车点,来避免车辆重新定位,校验手机位置是否合理。</p><h2>加速计</h2><p><img src="/img/remote/1460000043566175" alt="图片" title="图片"></p><p>加速计的原理是利用重力的作用来测量物体运动的变化。想象有一个球在盒子里,水平放置的时候,重力使得小球与下表面接触,下表面会产生一个向上的力,与重力相抵消,则产生一个向上的加速度,大小为G。</p><p><img src="/img/remote/1460000043566176" alt="图片" title="图片"></p><p>我们去采集了不同场景下车辆加速计的值,在理想的情况下图中波动的地方表示车辆在移动,平的地方表示车辆停下了。那么,如果将车辆中途等红绿灯和真正停驻进行区分呢?</p><p><img src="/img/remote/1460000043566177" alt="图片" title="图片"></p><p>为此我们进行了一些实验,用x、y、z三个值所在的平面去拟合x、y、z所在的分布,并计算俯仰角和横滚角,符合方程的被认为停驻,中间等红绿灯期间未被识别为停驻。在实际业务场景中,有加速计的车基本在用户点“还车”之前,85%以上的的订单都能进行预判。</p><h2>轨迹预测</h2><p><img src="/img/remote/1460000043566178" alt="图片" title="图片"></p><p>轨迹的本质是有时序关系的一串定位点,我们需要进行锐角剔除、速度校验、停驻平滑等预处理,同时结合路网情况对车辆轨迹中不合理位置及时剔除,结合预处理后的轨迹缺失情况、速度合理性、方向合理性生成偏差。</p><p><img src="/img/remote/1460000043566179" alt="图片" title="图片"></p><p>接下来介绍如何结合路网对轨迹纠偏。这里使用了隐马尔可夫模型,如图有一个原始的轨迹点P,在一定距离内有三个路段,轨迹点离旁边路段上的位置越近,那么这个点在这个路段上的概率越大。原始两个点的距离与投射之后两个点的距离越接近,转移概率越大。</p><p><img src="/img/remote/1460000043566180" alt="图片" title="图片"></p><p>我们结合停驻前的轨迹点的速度和方向进行轨迹预测,将原始轨迹点和预测点都用来作为是否可还车的判断,在这一段里的站点都认为是可以还车的点。</p><p><img src="/img/remote/1460000043566181" alt="图片" title="图片"></p><p>通过上述方法,我们将原始的还车链路进行了改造,将判责的部分放在用户感知之前,用户在停好车后点击还车即可离开,耗时大大缩短,提升了用户还车的体验。</p><p>(本文作者:高婷)</p><p><img src="https://segmentfault.com/img/bVc0Nn0" alt="图片" title="图片"></p>
Flutter技术在哈啰两轮的应用推广
https://segmentfault.com/a/1190000043540587
2023-03-15T11:24:41+08:00
2023-03-15T11:24:41+08:00
哈啰技术
https://segmentfault.com/u/hellotech
0
<h2>背景介绍</h2><p>Android应用采用Java或Kotlin编写,iOS应用采用Objective-C和Swift编写,但当我们要去开发支持多端的应用,每一端都需要独立研发、测试,直到上线。为了解决多端独立开发的问题,跨端技术的方案备受青睐。</p><p>两轮跨端技术的尝试要追溯到2019年,当时整个互联网行业都在提效的大背景推动下开始各种跨端方案的尝试,而前身还是两轮助力车团队的两轮大前端也开始了跨端技术方案的探索。当时可供选择的跨端方案有React Native方案、Weex方案,H5离线化方案以及当时较火的Flutter技术方案,而我们的目标是希望能够找到稳定、适合我们业务特性,并且可以继续进行深耕的技术方案进行调研和尝试。</p><h2>思考和调研</h2><p>主流的跨端方案主要分为两种,一种是将JavaScriptCore引擎当作虚拟机的方案,代表的框架是React Native。另一种使用自渲染引擎的方案,代表框架是Flutter。</p><h3>框架层+原生渲染方案</h3><p><img src="/img/remote/1460000043540589" alt="图片" title="图片"></p><p>它的开发语言选择了js,使用的语法和React完全一致,不用于一般React应用,它需要借助原生的能力来进行渲染,组件最终都会被渲染为原生组件。虽然给用户带来比较好的体验,但性能方面不会很高。</p><h3>以Web为基础的H5 Hybrid离线化方案</h3><p><img src="/img/remote/1460000043540590" alt="图片" title="图片"></p><p>相对来说这是开发成本最小的一种方案,因为它实际上是在写前端的界面,和普通开发H5网页并没有太大的区别。这一方案所面临的主要考验就是性能问题以及因为网络延迟而带来的用户体验问题。在此基础上我们引入了离线化的方案,我们计划是通过CRM平台发放H5离线包到达本地,使用WebView进行本地资源加载,来突破性能问题。</p><h3>框架层+自渲染引擎方案</h3><p><img src="/img/remote/1460000043540591" alt="图片" title="图片"></p><p>这种方案和上面的区别就是,它并没有直接借用原生能力去渲染组件,而是利用了更底层的渲染能力,自己去渲染组件。这种方式显然链路会比较短,性能方面也会更突出,同时在保持多端渲染一致性上面,也会比前面两种方案更加可靠,这类框架的典型例子就是Flutter。</p><h3>方案对比与选择</h3><p><img src="/img/remote/1460000043540592" alt="图片" title="图片"></p><p>我们在选择跨端方案的时候,不只是要考虑常见的几种重要指标,如编程语言、性能、技术架构等来判断是否适合我们团队和产品,更多还要去考虑开发效率、社区支持等工程化方面的指标,同时还要考虑团队现状、所选方案的生态和技术未来的发展方向。</p><h3>对比方案制定</h3><p>在社区、成熟度趋于一致的时候,跨平台方案的性能就成了重要的考虑因素和指标。性能更佳的Flutter当然是首选。另一方面,对于原本是Native开发的人来说,React Native与Flutter开发成本是相差不大的,从长远角度来看,选择Flutter技术方案似乎是较为合适的选择。</p><p>同时结合当时组内的情况,我们选择了H5 Hybrid离线化和Flutter作为比较方案进行端测的尝试。H5 Hybrid离线化方案相对来说比较简单,而且自主性比较强,方案逻辑简单风险较小。如果试验成功我们可以走出一条自主创新的跨端路线,而Flutter技术方案性能较高,社区也比较活跃,UI渲染框架做的较为彻底,也是当时跨端技术方案的代表方向。所以我们综合以上的考虑,希望通过实践拿到关键的对比数据,为后续最终的方案选择提供支持。</p><h2>Flutter是什么</h2><p>Flutter是Google的移动UI框架,可以快速在iOS和Android上构建高质量的原生用户界面,它是Google一个新的用于构建跨平台的手机App的SDK。写一份代码,可以多端运行,Flutter同时可以与现有的代码一起工作,被越来越多的开发者和组织使用,并且Flutter是完全免费和开源的。</p><p>从官方的介绍来看,Flutter的特点可以总结成三点。一是跨平台,现在Flutter 3基本可以实现跨6种平台,甚至支持嵌入式开发。到目前为止Flutter算是支持平台最多的框架了,良好的跨平台性,直接带来的好处就是减少开发成本。二是原生用户页面,让我们的体验更好,性能方面也会更好。用官方的话就是平滑而自然的滑动效果和平台感知,为用户带来全新的体验。三是开源免费,同Android系统一样,这些都是免费开源的。</p><h2>方案线上验证</h2><p>经过一段时间的调研和分析以后,我们基本上确定了两轮跨端方案的技术选型和对比方案。本着谨慎的原则,我们花费了一定的精力在这两个方案的验证对比、以及跨端技术方案的推进方法探索上。</p><h3>H5 Hybrid离线化方案</h3><p>针对该方案,我们的计划是分两期执行。第一期是完成框架的核心代码,并在BOS端完成一个页面的改造上线,拿到线上运行数据。第二期是在第一期数据的基础上打造CRM平台,完善方案的系统应用。</p><p><img src="/img/remote/1460000043540593" alt="图片" title="图片"></p><p>该方案的基本结构分为代码层、协议层、Hybrid离线SDK层以及离线包管理系统。其中的代码层是符合特定标准的H5工程开发产生的代码。当H5页面产生资源或样式的申请时,容器层会检测资源请求事件,并根据资源协议寻找本地资源包对应位置的资源并进行加载。本地资源加载完成后,形成数据流请求响应返回H5页面。</p><p><img src="/img/remote/1460000043540594" alt="图片" title="图片"></p><p>数据的流转在框架中经历了数据分发、协议解析、目标执行、结果反馈几个阶段。通过在端侧建立协议层的封装,保证了协议规则统一。整个协议过程做了SDK化的封装,保证了核心框架层的稳定。</p><h3>Flutter方案</h3><p>针对Flutter方案的尝试,我们需要在一开始优先解决工程结构、打包构建、上线集成和原生工程混合开发的问题。</p><p><img src="/img/remote/1460000043540595" alt="图片" title="图片"></p><p>针对于工程结构,Flutter在业务上的应用需要一个Host载体,我们选择了Flutter Module工程作为Flutter业务工程的Host工程。向上输出构建产物对原生工程的继承,向下组装和集合业务工程,形成统一的模块注入、页面注册的形式。</p><p><img src="/img/remote/1460000043540596" alt="图片" title="图片"></p><p>针对Flutter项目打包构建、上线集成和混合开发的问题,我们需要结合当时公司产物构建和管理规则进行Flutter产物的构建。我们的方案是在Jinkens上面创建单独的Job进行Flutter module产物的构建,最终把生成的Android和iOS产物集成到原生工程中,实现Flutter环境和代码的集成。以上便初步搭建完成了Flutter业务开发工程结构,并实现了在原生工程中混合开发、构建上线的一系列流程。</p><h3>比对实验</h3><p><img src="/img/remote/1460000043540597" alt="图片" title="图片"></p><p>在页面上线后,我们很快拿到了一手数据。在稳定性方面两种方案表现都比较好,没有出现页面crash的情况。在加载性能、白屏或者异常方面,Flutter方案要比H5 Hybrid离线化方案表现得更好。综合以上运行情况对比,我们基本确定了Flutter作为主要的大前端实践方案。</p><h2>Flutter应用推进</h2><h3>框架1.0阶段</h3><p>我们对Flutter技术工程化应用生产了各种轮子,以求达到对Flutter规范、高效应用的目的,比如数据的流转分发、网络模块的封装、MVVM结构的组织等。这一阶段的框架主要分为页面路由管理、数据总线、工程框架、网络请求、资源管理和组件管理。总体来说在这一阶段,我们对于Flutter应用基本形成了初具雏形的工程结构体系和基础能力建设。</p><h3>框架2.0阶段</h3><p>在这一阶段Flutter的应用算是进入了深水区,随着应用场景的增加,各种问题暴露出来,其中比较棘手的问题有Flutter状态管理、页面生命周期和混合插件缺失问题。针对Flutter状态管理、页面生命周期的问题,我们基于原生开发的经验,搭建了一套符合 MVVM标准的Flutter代码基础结构。</p><p><img src="/img/remote/1460000043540598" alt="图片" title="图片"></p><p>这套基础结构主要分为Page与VM两部分,组织关系如图所示。Page与VM逻辑分开,Page是对VM的封装,VM是业务逻辑的独立单元,包含了完整的UI以及逻辑层Module。</p><p><img src="/img/remote/1460000043540599" alt="图片" title="图片"></p><p>下面是Page基础结构功能定义说明。在这一结构中,我们是以PageRoute为基础进行扩展封装。PageLifecycle定义了生命周期和生命周期状态值;LifecycleEventNotify负责生命周期事件的传递;LifecycleNotifyManager是生命周期框架的核心枢纽,所有的事件汇总到这里向下调用生命周期API;LifecycleFromFlutter和LifecycleFromBoost收集flutter原生和flutter boost的生命周期的调用,总结调整后按照新的生命周期规范发送事件;PageAnimation是页面动画的集成工具;WidgetProviderRender是页面UI搭建的工具类,负责provider、widget的创建和组装,最终形成完整的页面;ModuleBinder是页面对Module的绑定工具,实现了module宿主的绑定,当宿主销毁时module能感知到并做出相应的反应。</p><p><img src="/img/remote/1460000043540600" alt="图片" title="图片"></p><p>下面是VM层基础结构。VM层和Page层基本类似,不同的是它会根据VM的特点进行自身生命周期的感知和处理,处理方式相对Page会更加复杂。</p><h3>Flutter Map组件</h3><p>针对以地图为代表的原生混合组件能力的建设,当时flutter社区的能力支持不足,没有合适的轮子使用,但是我们的业务场景又是比较重地图的。在这种情况下,我们就需要自己去开发一套地图组件。</p><p><img src="/img/remote/1460000043540601" alt="图片" title="图片"></p><p>如图是Flutter Map的总体结构图,我们利用的是flutter框架的PlatformView机制,在原生层实现view的实例化创建,再交给flutter进行UI的渲染,实现了原生view在flutter体系中的渲染和展示。接下来我们需要在flutter层与原生view进行操控,这里需要我们能够准确找到对应的原生view,并通过channel通道传达相关的指令。原生view收到指令以后,会根据规则做出相应的动作,并产生结果原路返回给调用方。原生view产生一些事件状态时,比如地图的拖动、加载完成等事件,需要及时传递给flutter端,同时flutter端有一些状态也需要传递给原生端,以便业务侧和原生view侧都能够及时做出相应的反应。这里我们以channel为基础建立了双工通道,实现了flutter与 platform两端实现实时进行事件通知的能力。</p><h2>Flutter标准体系化建设阶段</h2><p><img src="/img/remote/1460000043540602" alt="图片" title="图片"></p><p>在这一阶段我们的flutter在两轮大量的业务场景的应用实践下,基本趋于稳定的状态,经过我们在这一阶段对flutter的体系化改造,形成了集合flutter工程搭建、开发调试、基础能力沉淀以及开发标准输出的体系化结构。</p><h3>Git仓库整理</h3><p>在仓库结构方面,我们整合了散乱的flutter仓库维护状态,形成了现在统一、标准、聚合的仓库集合。目前的仓库叫HBFlutter,包括中间件子分组、基础组件子分组、native能力子分组和其他子分组。</p><h3>组件分类</h3><p>在基础能力沉淀方面,我们对现有基础组件进行了梳理和标准化改造,基本形成了一套体系化的flutter基础能力建设。</p><h2>技术总结</h2><p>Flutter技术目前在两轮得到了应用和推广,在哈啰App两轮业务场景下基本实现了全量Flutter化改造,Bos App也在今年开启了Flutter技术改造,目前已经基本实现大部分流程的Flutter化改造,目前线上运行相对平稳。两轮业务在应用Flutter技术后实现了开发效率的大幅度提升,同时也很大程度上解决了Android和iOS UI不一致的问题。后续我们将继续追踪Flutter技术的发展,并视业务场景的需求进行合理的应用和推广。</p><p>(本文作者:田克雨)</p><p><img src="https://segmentfault.com/img/bVc0Nn0" alt="图片" title="图片"></p>
哈啰电动车Taro多端组件库实践
https://segmentfault.com/a/1190000043485787
2023-03-01T11:10:43+08:00
2023-03-01T11:10:43+08:00
哈啰技术
https://segmentfault.com/u/hellotech
1
<h2>背景</h2><p>随着小程序业务的不断迭代,组件越来越多,导致组件规划不清晰、复用率较低、组件重复和代码混乱,以及还有其他如UI交互一致性和提升效率等需求。</p><p>对于组件库来说,实现功能重要,而清晰的文档则更加重要,不然因为团队沟通协调成本高,还是会造成各自为战的情况,不能很好的解决组件重复和代码混乱的问题。</p><p>抽离封装电动车Taro组件库以及组件库在线演示文档解决上述问题。</p><h2>方案选择</h2><p>在文档这一块,PC和H5都有比较成熟的方案框架,如dumi、VitePress等,但Taro这一块还没有成熟的方案框架,即使taro官方提供的taro-ui,组件也不够丰富,而且说明和示例不对应,增加了使用者的学习成本。</p><p>所以我们选择自己手动搭建组件库。</p><p>手动搭建还有一个优势,像dumi这种框架虽然成熟,但整体很重,里面封装了过多的功能,而且大部分代码是黑盒的。对于大多数团队,只需要使用其部分核心功能,然而做减法是困难且容易出错的。框架代码的黑盒也导致后续维护困难。而手动搭建虽然一开始工作比较多,但是后续维护非常容易。</p><h2>主要模块</h2><p>技术栈对于一套组件库,主要模块包括组件、文档、示例三大部分:</p><p>组件:es模块输出和按需加载是必须,rollup是最佳选择,组件编写方面,和业务项目保持一致,使用React,组件更适合使用hooks语法,然后ts,less这种就是常规项。</p><p>示例:这里是Taro组件库和常规组件库最大的区别,文档是运行在浏览器环境里的,所以想要组件demo可以展示,则需要使用Taro框架的打包h5功能。所以单独起一个项目用来跑demo代码,使用Taro React。至于怎么把文档里的代码引入到demo项目运行,则是使用webpack-chain自定义md文件的loader,自己写一个loader去实现。</p><p>文档:文档就是个静态页面,构建工具就选择vite,配置简单,编译速度快。markdown内容部分,使用react-markdown等插件渲染,根据路由读取不同文件即可。右侧预览部分使用iframe加载demo项目。</p><p><img src="/img/remote/1460000043485789" alt="图片" title="图片"></p><h2>目录结构</h2><pre><code>
├─config
│ ├─vite // vite配置
│ └─rollup // rollup配置
├─dist // doc打包产物
├─docs-dist // 文档打包产物
├─packages
│ ├─demo // 组件Taro demo项目
│ ├─doc // api文档项目
│ └─ui // 组件库
├─tests
├─index.html // doc入口文件
└─package.json</code></pre><h2>开发dev流程&路由关系</h2><p>以增加一个标签组件tag为例:在ui/components下新增tag文件夹,组件入口tag/index.tsx,文档文件tag/README.md,组件打包产物 /dist/tag/index.js;</p><p>doc项目增加对应的路由 /tag,路由渲染菜单到左侧,当路由切换到 /tag 时,通过路由组合出import url引入 tag/README.md 文件,通过vite框架的 ?raw引入方式,读取md文件中的内容为字符串传递给 react-markdown 组件,渲染成中间的文档页面;</p><p>demo项目独立运行,打包成h5页面,通过iframe嵌入到右侧,先增加与组件路由对应的页面文件,当doc的路由变化时,iframe的路由也同步变化,demo切换到对应的路由页面,然后就可以读取对应的 tag/README.md;</p><p>demo项目通过自定义markdwon-loader读取 tag/README.md 文件中写的示例代码,对于组件的引用,通过配置webpack路径别名的方式,把@hb/rent-taro-components 指向组件产物 /dist/tag/index.js;</p><p>demo项目通过自定义markdwon-loader 把当前文档中全部示例代码组合成一个可运行页面输出,即可实时预览到全部示例代码的运行。</p><p><img src="/img/remote/1460000043485790" alt="图片" title="图片"></p><h2>这套架构的优点</h2><p><img src="/img/remote/1460000043485791" alt="图片" title="图片"></p><ul><li>文档和demo在一个md文件输出,维护方便且灵活</li><li>多个demo代码单独编写,互不影响,清晰简洁</li><li>右侧demo项目独立,如有需要,可单独发布demo</li><li>小程序各模块功能清晰无黑盒,后续维护容易</li></ul><h2>各模块说明</h2><h3>doc</h3><p>doc目录结构</p><pre><code>├─src
│ ├─components
│ │ ├─markdown-render // md文件渲染组件
│ │ └─... // 其他布局组件
│ ├─guides // 指南文档
│ ├─router
│ │ ├─comp-doc.ts // 组件路由
│ │ ├─guide-doc.ts // 指南路由
│ │ └─index.ts // 统一导出
│ ├─app.less
│ └─App.tsx // 主页面
└─main.tsx // 入口文件</code></pre><p>router<br>comp-doc.ts:</p><pre><code>
import Button from '@ui/button/README.md?raw';
import Tag from '@ui/tag/README.md?raw';
const compRoutes = [
{
name: '基础组件',
path: '/basic',
children: [
{
name: '按钮',
path: '/button',
component: Button,
},
{
name: '标签',
path: '/tag',
component: Tag,
},
],
},
];
export default compRoutes;</code></pre><h3>demo</h3><p>demo目录结构<br>除了增加了一个自定义loader,其他文件结构和标准Taro项目完全一致。</p><pre><code>
├─config
│ ├─dev.js
│ ├─index.js // Taro webpack配置
│ ├─markdownloader.js // 自定义md文件loader
│ └─prod.js
├─src
│ ├─pages
│ │ └─basic // 文件夹路径与doc路由保持一致
│ │ ├─button
│ │ │ ├─index.config.ts
│ │ │ └─index.tsx
│ │ └─tag
│ ├─app.config.ts // 路由配置
│ ├─app.less
│ ├─app.ts
│ └─index.html
└─package.json</code></pre><p>route&核心代码<br>app.config.ts:<br>除了 /index 部分,路由与doc路由一一对应。</p><pre><code>
export default {
pages: [
'pages/basic/button/index', // pages和index之间的部分对应doc路由
'pages/basic/tag/index'
],
...
}</code></pre><p>pages/basic/button/index.tsx:<br>只需要把 md文件当做组件引入并 export即可,md 经 markdown-loader 解析后就是一个可运行组件。</p><pre><code>
// 直接引入组件库中README.md作为demo组件,代码解析在自定义loader中完成
import Demo from '@components/button/README.md';
export default Demo;</code></pre><p>config/index.js:<br>通过 webpackChain 自定义loader。</p><pre><code>
const config = {
h5: {
// ...
webpackChain (chain, webpack) {
chain.merge({
module: {
rule: {
mdLoader: {
test: /\.md$/,
use: [
{
loader: 'babel-loader',
options: {}
},
{
// 引入自定义loader
loader: `${path.join(__dirname, './markdownLoader.js')}`,
options: {}
},
]
}
}
}
})
}
// ...
}
}</code></pre><h3>ui</h3><p>ts文件配置<br>通过rollup-plugin-typescript2配置对应的config文件。</p><pre><code>
import RollupTypescript from 'rollup-plugin-typescript2';
// ...
plugins: [
// ...
RollupTypescript({
tsconfig: resolveFile('config/tsconfig.rollup.json'),
}),
]</code></pre><p>按需加载</p><ul><li>多入口打包</li><li>通过rollup-plugin-postcss抽离样式文件</li><li>通过rollup-plugin-copy直接移动less文件到dist(因为对于业务项目,不需要打包成css)</li></ul><pre><code>
import RollupCopy from 'rollup-plugin-copy';
import RollupPostCss from 'rollup-plugin-postcss';
const inputD = {};
compFiles.forEach((item) => {
const val = item.replace(cwd, '').replace('/packages/ui/src/components/', '').replace('/index.tsx', '');
inputD[`${val}`] = item;
});
const config = {
input: inputD,
plugins: [
RollupPostCss({
use: [
[
'less',
{
javascriptEnabled: true,
},
],
],
extract: 'index.less',
extensions: ['.css', '.less'],
makeAbsoluteExternalsRelative: false,
}),
RollupCopy({
verbose: true,
targets: [{
src: resolveFile('/packages/ui/src/components/*/*.less'),
dest: resolveFile('/dist/styles'),
}],
}),
]
}</code></pre><p>(本文作者:范翔宇)</p><p><img src="https://segmentfault.com/img/bVc0Nn0" alt="图片" title="图片"></p><blockquote>本文系哈啰技术团队出品,未经许可,不得进行商业性转载或者使用。非商业目的转载或使用本文内容,敬请注明“内容转载自哈啰技术团队”。</blockquote>
ChatGPT的炼成方式和在哈啰营销落地能力
https://segmentfault.com/a/1190000043464186
2023-02-23T16:37:03+08:00
2023-02-23T16:37:03+08:00
哈啰技术
https://segmentfault.com/u/hellotech
0
<p>ChatGPT是由OpenAI开发的大型语言模型,可以帮助我们解决很多日常生活中的事情,如更改错误、写小说、回答问题、翻译、写文案等。</p><p><img src="/img/bVc6w7S" alt="" title=""></p><h2>GPT 的演进</h2><p>GPT一共有三代,即GPT-1,GPT-2,GPT-3,目前非常火的ChatGPT是GPT-3.5。GPT-1诞生于2018年6月,比BERT略早几个月,层数为12层,词向量长度为768,训练参数量为1.17亿个,数据量为5GB;时隔半年之后,GPT-2诞生于2019年2月,训练参数量为15亿个,数据量为40GB;GPT-3诞生于2020年5月,训练参数量增长超过100倍至1750亿个,数据量达到45TB。</p><p><img src="/img/remote/1460000043464189" alt="图片" title="图片"></p><p>GPT-1以Transformer 为核心结构,是自左向右单向的。GPT-2提出了“所有的有监督学习都是无监督语言模型的一个子集”的思想,即它是一个无监督模型,中间所有的过程都是通过无监督去完成的。对比GPT-2,GPT-3的模型结构上没有做任何的改变,它使用了超大的数据和参数量,真正诠释了什么叫“暴力出奇迹”。GPT系列虽然都取得了不错的成果,但始终会存在一个问题,即怎么样让它无害,它会生产出一些假的新闻,造成不好的社会影响。</p><h3>GPT-1</h3><p>自然语言里面有很多未标注数据,标好的数据比较少。GPT-1面临的问题是在没有标注的地方学习一个语言模型,在标好的数据上训练一个小模型。在做无监督的时候,我们会遇到两个最大的问题,一是不知道目标函数是什么,二是怎么传递到下一个子任务。</p><p><img src="/img/remote/1460000043464190" alt="图片" title="图片"></p><p>GPT-1采用的是传统语言模型的方式,k是窗口的大小,窗口越大就代表整个任务会更难。</p><p><img src="/img/remote/1460000043464191" alt="图片" title="图片"></p><p>将这些预测的概率向量和它的位置编码进行结合,就可以得到h0,h0通过transform解码器去进行解码,最终就得到它的编码,最后会接上一个微调模型。</p><p>这是GPT-1里面的应用,主要包括分类、蕴含、相似和多选,每一类任务都有一个标记,告诉这是任务的开始阶段、中间阶段还是结束阶段,如果是分类任务就在开始和结束阶段中间抽取一个text,开始和结束符号一定是特殊符号,最好不要在这些文本当中出现,最后我们再接一个Linear的分类器。二是蕴含,即B是否能够支持A,举个例子,小王和小李是好朋友,如果后面一句话是小王送给小李一个馒头,那么它的结果可能是正确的,这句话能证明他们是好朋友。如果是小王今天中午吃了一个馒头,这并不能证明小王和小李是好朋友。三是相似度的训练,类似头条的去重可以用到这样的算法。四是多选,即有A、B、C三个选择,应该去选择哪一个。当然整个的效果是不如BERT的,从技术难度上来说,BERT会更简单,并且GPT-1用的数据本身没有BERT那么大。</p><p><img src="/img/remote/1460000043464192" alt="图片" title="图片"></p><h3>GPT-2</h3><p>GPT-2模型来自论文《Language Models are Unsupervised Multitask Learners》,它希望通过zero-shot有所创新,即对于下游任务,不需要标注信息,在任何地方都能用。这里不能引入之前模型没见过的符号,提示符看上去更像一句话,这也是ChatGPT最初的一个版本,冒号前面告诉它你要做什么事情,如在英语到法语的翻译任务中,给模型一个英语和法语。或者告诉模型去回答一个问题,这个问题是什么,它会告诉你答案是什么。作者在阅读理解、翻译、总结和回答问题上进行实验,可以发现GPT-2在阅读理解和回答问题上效果会更好一些,同时当它的数据量越大,模型能够继续上升。</p><p><img src="/img/remote/1460000043464193" alt="图片" title="图片"></p><h3>GPT-3</h3><p>GPT-3模型来自论文《Language Models are Few-Shot Learners》,受到了zero-shot的启发,我们发现用大量的数据去做标注很困难,但如果一个样本都没有,它的泛化性不一定好,同时人类不需要很大的数据去做任务。这里用了两个方法,一是元学习,二是in-context learning。</p><p><img src="/img/remote/1460000043464194" alt="图片" title="图片"></p><p>接下来我们来看一下Zero-shot、One-shot、Few-shot和Fine-tuning的区别。最常见的是Fine-tuning,即会给一批新的数据,需要对原来的数据做一定的梯度更新;Zero-shot是说只给提示,剩下自己去做;One-shot是说会告诉你去做什么,还会给一个示例;Few-shot是说会给更多的示例,告诉任务应该做成什么样。In-context learning是它的核心,指我们对模型进行引导,教会它应当输出什么内容。</p><p><img src="/img/remote/1460000043464195" alt="图片" title="图片"></p><p>作者对这3种学习方式分别进行了实验,可以看到在效果上Few-shot> One-shot > Zero-shot,且参数量越大模型表现越好。</p><p><img src="/img/bVc6w9S" alt="" title=""></p><h2>ChatGPT的原理</h2><p>ChatGPT的训练可以分成三步,第一步是需要去做一个有监督的模型;第二步是去收集数据给模型一个反馈,即做强化学习;第三步是根据强化学习,去优化原来的模型。</p><p><img src="/img/bVc6w9s" alt="image.png" title="image.png"></p><p>整个训练过程可以分为四个阶段,包括文字接龙、找一个老师、让老师给评分以及成为老师。</p><p><img src="/img/remote/1460000043464196" alt="图片" title="图片"></p><h3>文字接龙</h3><p>ChatGPT的第一个学习阶段是文字接龙,当我们给出一个不完整的句子,如“这个大白”,GPT会接下一个字,如“大白天”、“大白美”、“大白丑”,每次输出都会不一样,但是它会有一个概率。这里我们举个例子,如胡歌很帅,它刚开始学的就是胡,去预测胡歌;已知胡歌,去预测胡歌很;已知胡歌很,去预测胡歌很帅,整个过程完全不需要人工标注。</p><p><img src="/img/remote/1460000043464197" alt="图片" title="图片"></p><p>文字接龙有什么用呢?它就能够帮我们回答很多问题。如它在网上看到了一句话叫做“中国最大的淡水湖”,然后让它回答问题,它可以一个个字接下去,就可能会回答鄱阳湖。当然如果这样去做,它的准确率是非常低的,因为没有标注的数据,质量都是不可控制的。</p><p><img src="/img/bVc6w92" alt="image.png" title="image.png"></p><p>比如说你问它“中国最大的淡水湖”,它可能回答“鄱阳湖”,也有可能回答“这个问题谁知道呢”,还有可能回答网上的一个选择题“是鄱阳湖还是太湖呢”。那么,怎么让它输出稳定下来变得更加可控呢?</p><p><img src="/img/bVc6w96" alt="image.png" title="image.png"></p><h3>找一个老师</h3><p>要达到一个可用的状态,就要给它找到一个老师,去提供正确的答案,当然这种答案不需要特别多,ChatGPT里面大约给了一万个正确的答案。老师就会告诉它“中国最大的淡水湖是鄱阳湖”,然后对这些正确的答案加上更多的权重,告诉它人类的偏好是这样的,激发它本来的力量,本来ChatGPT也有能力生成这些答案。</p><p><img src="/img/bVc6xah" alt="image.png" title="image.png"></p><h3>让老师给评分</h3><p>当它找到老师以后,就可以去慢慢模仿一个老师的喜好。当GPT去输出“鄱阳湖”、“太湖”的时候,会有一个判别器告诉说得分是多少,如果是“鄱阳湖”就可以给它更高的分数。</p><p><img src="/img/remote/1460000043464198" alt="图片" title="图片"></p><h3>成为老师</h3><p>在得到评分的标准后,我们需要把标准告诉GPT,让它知道这个答案是正确的,这就是比较常见的强化学习。即会告诉你,如果你回答对了,我会给你一个奖励,然后你去反馈到GPT当中去,给鄱阳湖加上更多的权重,这样ChatGPT就会自己成为老师,知道什么样的答案是正确的答案。</p><p><img src="/img/remote/1460000043464199" alt="图片" title="图片"></p><h2>ChatGPT在营销的应用</h2><p>ChatGPT最核心的观念有两个,一是使用了超大的参数,二是给数据做高质量的标注。这可以给算法同学一个启示,我们大部分时间可以不花在怎么用一些DIN、DCN、DeepFM之类的模型,更重要的是需要去给它更多的数据,加大它的参数量;二是高重量的标注,训练样本的质量一定要高,不能给一些错误或者模糊的答案,要给的数据标签一定要是非常正确的标签。在哈啰目前还没有一个超大模型出现,应用在推荐、营销、定价等各个方向。</p><p><img src="/img/bVc6xaP" alt="image.png" title="image.png"></p><p>应用场景主要有两个,一是逛逛,在ChatGPT上面我们可以告诉它一句话,然后它可以去生成图片,或者在逛逛里面的一个问题,我们可以用ChatGPT去辅助回答。二是运营同学在做广告标签的时候,我们可以去让ChatGPT生成这些标语,拿过来给它十句左右的提示语,适应哈啰的场景。</p><p><img src="/img/remote/1460000043464200" alt="图片" title="图片"></p><blockquote>本文参与了<a href="https://segmentfault.com/a/1190000043397696">SegmentFault 思否写作挑战赛</a>,欢迎正在阅读的你也加入。</blockquote>
出海Native地图与Web融合技术尝试与实践
https://segmentfault.com/a/1190000043423503
2023-02-14T15:20:56+08:00
2023-02-14T15:20:56+08:00
哈啰技术
https://segmentfault.com/u/hellotech
0
<h2>背景</h2><p>哈啰出海项目目前是基于Google地图提供的服务进行地图相关能力的场景应用。</p><p>在H5侧Google动态地图能力是一项收费服务,对于初期我们进行海外业务拓展探索中,这部分费用占据了出海营收的一部分。为了减少Google地图的费用支出,前期我们也进行一系列的产品侧、研发侧的优化,目前整体 Dynamic maps 单均消费大约下降了50%,但这远远还是不够。</p><p>随着业务的不断迭代上线,Native地图相较于Web地图的天然性能优势日趋明显,首屏体验也随之差距越来越大。</p><h2>目标价值</h2><p>为了改善现状,我们希望通过在业务层使用Webview承载H5,展示地图部分则使用Native地图,而非采用Web地图。</p><ul><li>解决Google地图在Web侧服务的收费问题,达到降本的目的。</li><li>提升用户体验,追平与竞品地图性能差距。</li><li>保持H5的开发灵活性,无需维护两套核心主流程业务代码,方便维护的统一性。</li></ul><h2>设计方案</h2><p>基于出海现有的需求,我们采用Native地图+H5页面的设计原则,既希望能够正常处理H5页面的事件,又要满足Native地图的相关事件派发。</p><p>调研初期,我们曾设想过通过Native地图+多个webview容器承载页面散落的元素。</p><p><img src="/img/remote/1460000043423505" alt="图片" title="图片"></p><p>这种设计方案虽然解决了布局问题,但是却存在不少不足:</p><ul><li>多个Webview之间内存空间不共享,信息同步问题难度较大</li><li>一个业务页面存在多个Webview组件对系统性能是一个高度浪费</li><li>核心主流程中涉及多个地图页,相关改造成本偏高,不确定性高,拓展性差</li></ul><p>因此,该方案也满足不了出海场景的需求。</p><p>在探索中,我们借鉴了业内的思路,并进行了可行性评估。最终从出海场景出发,实践出一套基于React的融合方案。</p><h2>框架设计</h2><h3>融合方案设计</h3><p>页面架构图层<br><img src="/img/remote/1460000043423506" alt="图片" title="图片"></p><p>融合技术架构图<br><img src="/img/remote/1460000043423507" alt="图片" title="图片"></p><p>数据通讯流程图<br><img src="/img/remote/1460000043423508" alt="图片" title="图片"></p><p>预期效果</p><ul><li>当我们点击 Webview 内的H5元素时,点击事件派发到 Webview 容器处理</li><li>当我们操作地图区域时,操作事件派发到 Native 层地图组件处理</li><li>通过JSBridge实现 Native 地图与 Webview 层的信息通讯</li></ul><p>核心思路</p><ul><li>思路为页面模块为 Native 地图层+ Webview 层,Webview 层置于 Native 地图层之上并保持透明</li><li>通过 Web 提供热区数据模块分布,Native 区分热区进行 Native地图层或 Webview 层逻辑分发</li><li>Native 地图层和 Webview层之间的数据、事件通信通过JSBridge进行相互通信</li><li>Native 地图层主要负责提供通用型地图渲染类,供 Web 调用</li><li>Webview 层负责地图渲染数据渲染层处理,不负责地图 UI 绘制渲染</li></ul><p>看到这里,有的人可能就会产生疑问,Native如何根据热区进行区分?热区模块如何定义?</p><h3>热区数据与热区坐标</h3><p>其实在摸索出来之前,我们并不知道Native还有此等“神力”可以再一个页面里对事件进行有效派发,故当我们确认了这个能力能够得到有效支持时,我们约束好相关Webview层热区的定义规则,对热区数据进行统一维护与定义。</p><p>我们以左上角作为坐标原点[0,0]进行热区的坐标定义,做好事件分发策略。如果手势消息的发生产生在热区之内,则消息派发到Webview 层,否则派发到 Native层。至于热区像素坐标格式,则采用基于Webview组件左上角为原点[left, top, width, height]。毕竟前人栽树后人乘凉,没必要整太多花活,解决有效问题最关键。</p><p><img src="/img/remote/1460000043423509" alt="图片" title="图片"></p><h4>动态更新</h4><p>由于不同热区模块存在动态变更的场景,所以我们还需要考虑热区的动态更新。</p><pre><code>import Bridge from '@/Bridge'
const getHotData = (data: number[]) => {
//...
return [0,0,100,100]
}
useEffect(() => {
if (cardRef.current) {
// 获取当前卡片dom元素的相关信息
const rect = cardRef.current.getBoundingClientRect();
// 转化成传输的格式坐标规则
const coordinates = getHotData(rect)
// 通过 Bridge 传递 Native
Bridge.setHotZoneInfo(coordinates)
}
}, [
cardRef,
]);</code></pre><p>以上核心代码可以将指定的元素纳入动态热区的监听当中,但还有一部分业务侧的交互是比较复杂且不好收口的,比如不同组件之间的弹窗、Popup、Confirm之类的一系列全屏元素,这部分的动态更新策略是值得探讨的。</p><p>下面我们将介绍一下基于这个方案所做的全屏元素动态更新热区的策略。</p><h4>全屏元素监听动态更新</h4><p>这里我们采用的是MutationObserver接口,它提供了监视对 DOM 树所做更改的能力,该功能是 DOM3 Events 规范的一部分。DOM 的任何变动,比如节点的增减、属性的变动、文本内容的变动,这个API 都可以得到通知。</p><p>特点:</p><ul><li>异步执行,并不会立马执行,等待所有DOM改变结束后触发</li><li>变动记录封装成数组处理,而不是一条条处理DOM变动</li><li>可观察DOM节点所有变动 ,也可观察某一类变动</li></ul><p>因为其异步执行的天然优势,少了 DOM 的频繁变动,大大有利于性能。</p><p>在出海主流程的场景里,我们只希望观测弹窗、Popup等类似的铺满整个屏幕的元素,所以这里我们只需要关注宽高与客户端宽高匹配的元素。通过Dom元素的getBoundingClientRect方法拿到对应元素的相关信息。</p><pre><code>const width = document.documentElement.clientWidth || document.body.clientWidth;
const height = document.documentElement.clientHeight || document.body.clientHeight;
const clientRect = Dom.getBoundingClientRect()
const isFull = clientRect.width === width && clientRect.height === height</code></pre><pre><code>const config: MutationObserverInit = {
attributes: true, // 观察目标属性改变
attributeOldValue: true, // 记录改变前的目标属性 方便对比
childList: true, // 表示观察目标子节点变化 添加/删除
subtree: true, // 目标及目标后代改变都监听
attributeFilter: ['id', 'class', 'style'], //设置需要被监听的属性
};</code></pre><p>变动执行回调函数</p><pre><code>let elementPath = [] // 用于维护当前铺满整个屏幕的元素,用于多蒙层之类的场景判断
const callback = (mutationsList: MutationRecord[], observer: MutationObserver) => {
try {
for (const mutation of mutationsList) {
const { target, type, attributeName, addedNodes } = mutation;
if (type === 'childList') {
let tag = false;
if (addedNodes.length) {
// ...
} else {
// ...
}
} else if (type === 'attributes') {
if (attributeName === 'class') {
//...
} else {
// attributes变化
}
}
}
} catch (e) {
//异常场景 兜底走H5地图
}
}</code></pre><p>整个全屏判断就都在回调函数里进行判断 ,如果当前有铺满屏幕的 H5 元素 则通过 Bridge 告知 Native 整个手势派发由Webview接管,否则按原有的热区模块进行逻辑分发。</p><p><img src="/img/remote/1460000043423510" alt="图片" title="图片"></p><p>每次的Callback触发中,我们主要依旧三个核心思路进行元素的查找定位:</p><ul><li>根据变动节点,向上查找,直到BODY结束</li><li>根据变动节点,向下查找,把节点父容器进行元素遍历</li><li>根据全屏元素路径,定向查找,把原有记录全屏的节点进行重新校验</li></ul><h2>测试工具</h2><h3>关于热区</h3><p>我们基于vConsole编写了一个简易的插件进行相关Bridge的调用以及热区的测试。通过Canvas将热区绘制在屏幕中进行相关数据的呈现,快速诊断和排查热区异常情况。</p><p><img src="/img/remote/1460000043423511" alt="图片" title="图片"></p><h3>关于地图Bridge</h3><p>目前我们主要在进行相关地图通用能力接口的编写,通过对出入参的数据收集以及整个交互过程配合控制台的输出进行边界问题排查,后续将通过单测的方式对其进行自动化测试。</p><p>这一实践,有效的将Webview和Native组件融合起来,通过该融合技术为出海业务有效提升开发迭代效率,同时保障了用户地图的体验。除了首次发版依赖App发版、审核、下载等繁琐流程,拥有了需求随时可发、随时能发的能力。</p><p>(本文作者:黄鸿达)</p><p><img src="https://segmentfault.com/img/bVc0Nn0" alt="图片" title="图片"></p><blockquote>本文系哈啰技术团队出品,未经许可,不得进行商业性转载或者使用。非商业目的转载或使用本文内容,敬请注明“内容转载自哈啰技术团队”。</blockquote>
对于研发自测上线项目,测试同学可以做点啥?
https://segmentfault.com/a/1190000043394491
2023-02-07T14:36:30+08:00
2023-02-07T14:36:30+08:00
哈啰技术
https://segmentfault.com/u/hellotech
0
<p>在软件研发过程中,不可避免的存在由研发自测后上线的项目。在这种完全由研发同学独立完成开发、测试、发布上线的项目,测试同学可以提前为研发同学做点啥?</p><p>我们算法测试团队,提出了四步曲的设想:</p><h2>第一步:定标准</h2><p>定标准,即明确可研发自测上线的范围。业界对研发自测的标准非常多,我们建议遵循以下三个维度来制定:</p><h3>影响面</h3><ul><li>对核心链路有影响,则测试介入</li><li>对公司核心业务有影响,则测试介入</li></ul><h3>复杂度</h3><ul><li>涉及复杂链路或复杂逻辑,则测试介入</li><li>难以通过现有的简单测试手段来测试,则测试介入</li><li>涉及架构变更(如技术方案升级、应用功能拆分等等),则测试介入</li></ul><h3>工作量</h3><ul><li>研发投入时长 >= X人/日(X由各个公司自行定义),则测试介入</li></ul><p>不满足上述三个维度标准的项目,可以考虑研发自测上线。</p><h2>第二步:赋能</h2><p>赋能,指帮助研发做好自测。我们从研发自测整个实施过程来看,可以做哪些赋能动作:</p><h3>测试准备:</h3><p>1.测试数据准备:对于聚焦在具体模块或应用的研发同学而言,上下游测试数据准备,是阻碍研发同学做好自测的一大痛点。因此:</p><ul><li>可以看下测试工具是否完备,如是否有一站式造数平台——数据工厂,数据Mock平台等数据准备工具;</li><li>我们可以再往前走一步,看看是否能将上述的平台能力进一步封装成简单易用的组件,研发同学只需要点一下组件,就能生成所需要的测试数据。</li></ul><p>2.测试环境准备:测试环境稳定性,是阻碍研发进行自测的第二大痛点。时不时的环境异常,会给测试结果带来非常多的噪声,降低研发自测的积极性。因此:</p><ul><li>是否有独立的联调环境,在该环境下可独立部署应用依赖的上下游服务,通过容器化方式一键拉起部署,用完自动回收;</li><li>我们还可以再往前走一步,为研发做好平台使用培训,积极推广好的实践案例。(历史经验表明,人都是有惰性的,即使再好的工具,也可能不被发现)</li></ul><p>3.测试场景准备:上下游全链路的测试场景设计,往往是研发同学的盲区,对上下游的不了解,使得他们无法确定该做哪些测试动作。而具备全局视野的测试同学,天生具备为研发提供核心链路测试场景的条件。因此:</p><ul><li>为研发同学提供一套最小合集的主流程核心测试场景。</li><li>需要我们努力提高测试场景的可读性和可执行性。降低研发同学的执行成本,才能最大化的实现自测场景的覆盖占比。否则,再完善的用例集,也只能用来看!</li></ul><h3>测试实施:</h3><p>1.提高测试实施效率:人都是有惰性的,如果一个测试场景的测试实施过程需要经历:测试环境搭建,测试数据准备,mock接口准备,测试步骤逐个操作,逐步查看结果反馈等。我相信,这个过程已经劝退了很多人。因此,我们需要秉承:</p><ul><li>能自动化实施的,均提供自动化的能力。比如研发在提交代码后,自动触发自动化测试执行流水线:环境搭建->数据准备->测试场景自动化执行->测试结果自动汇总。</li><li>能做能力聚合的,均依靠平台化建设,将能力聚合在一起。比如,测试实施不仅仅包含用例场景的自动化测试,还包含复杂的业务效果评测、性能压测、故障演练等测试类型。如果能够依托平台化,为研发输出一站式的测试能力集,由其自行组合,自行测试实施。</li></ul><p>2.关注结果反馈的3大要求:</p><ul><li>反馈时效性:测试结果的反馈,时效性非常重要,漫长的等待不仅降低过程效率,也会消磨人的积极性。因此,对于高频的测试项,如场景的自动化测试会约定10分钟内给结果。而对于相对低频的测试项,如业务效果评估,稳定性测试则可适当延长。</li><li>反馈指标完整性:测试结果反馈需要展现哪些指标项,需要由测试同学来制定标准,从而实现无论哪个研发自测,都能按图索骥的操作,输出合格的自测过程与结论。</li><li><p>反馈内容可靠性:由于测试实施过程中的各种不可控因素非常多,因此测试结果中会混入噪声。如何降低噪声,一般有2个方向:</p><ul><li>及时维护测试相关组件的有效性(如上文提到的各种技术能力,测试场景等)</li><li>依托技术能力,自动筛选噪声和部分解决噪声。然后通过平台的交互设计降低噪声干扰。</li></ul></li></ul><h2>第三步:可管控</h2><p>可管控,指在上线前能够衡量测试效果和规划发布过程,在上线过程中能够发现质量风险,在上线后能够监控线上质量。比如:</p><h3>发布计划</h3><p>通过发布计划的设计,来满足对测试效果和发布实施步骤的检查。一般发布计划会涵盖如下内容:</p><ul><li>发布需求说明(体现需求、变更范围、影响范围、代码说明等)</li><li>测试报告及结论(测试效果检查,具体指标项,在此不做扩展)</li><li>配置变更说明及检查结论(涵盖权限,静态、动态配置,网关,数据库,缓存,消息,埋点等)</li><li>依赖关系说明及检查结论(上下游调用关系,业务链路等)</li><li>发布方案及步骤说明(灰度方案,监控方案,应急方案,发布时间等)</li></ul><h3>变更三板斧</h3><p>依赖公司的变更三板斧,实现:</p><ul><li>可观测:是否可进行观测(监控)且确认变更成功</li><li>可灰度:是否可以进行灰度发布(应用发布,配置变更等均需支持)</li><li>可应急:是否有应急预案(降级,限流,回滚等),且可实施生效</li></ul><h2>第四步:营造氛围</h2><p>营造氛围指测试自身的工作氛围和测试与研发合作的工作氛围两个层面:</p><ul><li>测试自身:在平时的工作中,需要形成注重效能提升的氛围。主动将重复的、手动的测试工作,往工具化上沉淀,往平台化上拓展。将测试能力变成可输出的平台化能力,赋能给其他角色。</li><li>测试研发合作上:对于研发自测的项目,测试同学千万不能以为交给研发负责上线质量了,就可以不管不顾。我们依然需要做到:服务好过程,跟踪好结果,跟进好问题。营造和谐融洽的合作氛围,帮助研发做好自测,就是帮助测试自身,帮助产品最终成功。</li></ul><p>(本文作者:陈震)</p><p><img src="https://segmentfault.com/img/bVc0Nn0" alt="图片" title="图片"></p><blockquote>本文系哈啰技术团队出品,未经许可,不得进行商业性转载或者使用。非商业目的转载或使用本文内容,敬请注明“内容转载自哈啰技术团队”。</blockquote>
我们是如何保障哈啰930大促的
https://segmentfault.com/a/1190000043373263
2023-02-01T15:26:35+08:00
2023-02-01T15:26:35+08:00
哈啰技术
https://segmentfault.com/u/hellotech
0
<p>业界有很多大促活动,像618、双11、双12等等。每一次大促不只是给业务带来了新高,对于技术同样也有很重要的意义,纵观一些优秀的技术团队,都是跟着业务一起成长的。在高并发大流量的背景下,如何支撑好业务运营,是一件很有挑战性的事情,它可以从多方面检验我们的技术能力,对我们的系统架构和应急保障都提出了很高的要求。</p><p>哈啰在去年9月30日开启了首届的假日狂欢节,我们也做了很多的稳定性保障工作,最终大促顺利渡过,业务体感非常顺滑,所以借此机会总结下我们在稳定性保障这方面的一些工作,分享给大家。</p><h3>一、大促保障与日常保障的差别</h3><p>相比日常的稳定性保障来说,大促的主要特点是<strong>时间短、流量大、玩法丰富</strong>。大促的过程一般都只有几天甚至数小时,为了尽可能冲到新的业务目标,要充分调动用户的积极性,所以营销玩法一般都比较丰富。因此,我们在大促的稳定性保障中,重点从<strong>容量规划、压测演练、应急预案、变更管控</strong>这几个维度来做保障。</p><p><img src="/img/bVc59wn" alt="image.png" title="image.png"></p><p>哈啰由于多业务形态共存,每条业务线有自己的发展特点,因此大促开始前,<strong>要充分分析业务特点,制定针对性的保障方案</strong>。例如两轮业务(共享单车和共享助力车等),与典型的通勤场景是一致的,在早晚的上下班高峰期,后台的系统流量也会特别大。而四轮业务(打车、顺风车、送货等),则是跟着节假日有较强的关联性,每到节假日前夕,系统可能会面临数倍于日常流量的峰值。</p><h3>二、大促的整体流程</h3><p><img src="/img/bVc59wo" alt="image.png" title="image.png"></p><p>顺着大促的时间轴来看,一般会包含活动早期的方案制定、压测演练 ,活动中的应急预案和联动等,活动后的收尾工作等。下面按顺序介绍下其中的一些重点工作。</p><p><strong>1、组织保障</strong></p><p>公司级大促,需要多个业务线共同协作,涉及多个部门众多人员,这个时候需要有一个类似<strong>大促保障组之类的组织来统筹协调</strong>,这个组织是大促保障的决策机构,它是大促保障的第一责任人,同时也赋予相应的权利:在涉及到资源冲突的时候,大促保障组有权拍板,能与业务运营沟通需求,特殊情况下,可以停掉一些非必要的迭代需求,保障大促的事项顺利推进。</p><p>同时,各个业务研发团队,也需要明确各自的<strong>大促负责人(</strong>也称大促技术PM<strong>)</strong>,<strong>大促技术PM是本业务研发团队大促保障的首要负责人</strong>,要根据业务特点制定详细的保障方案,配合本业务完成大促目标。</p><p>在分工上,大促保障组负责关键里程碑的设定,比如说大促项目需求上线时间、什么时候开始压测、什么时候进行封网、封网之后的变更审批(破网)流程,以及给出整体的保障方案框架,例如大促保障模版、技术目标拆解方式等等,可以供业务线的大促技术PM来作为参考依据。</p><p>在沟通机制上,大促保障组要能够从<strong>全局视角给出当前的整体工作进度和风险概况</strong>,及时汇报给管理层。同时<strong>各个业务线的大促技术PM也需要定期向大促保障组汇报工作进展</strong>,包括各业务线的大促需求研发进展、当前已知风险、资源瓶颈等等。</p><p><strong>2、目标拆解</strong></p><p>我们上面提到过,大促的核心在于对流量的把控,因此目标拆解的目的主要是从<strong>业务目标拆解为技术目标</strong>。</p><p>在业务目标设定的时候,一般会考虑用订单量/GMV/DAU/PV/UV等各类指标作为目标,但是在我们做保障方案的时候,需要明确地知道技术目标,一般是QPS、用户数等等。</p><p>所以这里需要有一个转化的过程,首先是与业务运营深入沟通,搞清楚这次大促的主要策略和玩法,核心点是弄明白这个问题: 相比日常的业务流程来说,<strong>这次的流量从哪里来,这个玩法是如何吸引用户的,用户的操作路径与以往会有哪些不一样</strong>。搞清楚这个,就明白了<strong>流量路径</strong>。比如以两轮骑行为例,会考虑通过一些骑行任务来提升用户的骑行意愿:骑行N单之后给X元奖励。那这里面就需要关注骑行流程、营销活动、任务奖励等这几个平台相关的各个系统。同时进一步深入分析,还发现在大促活动中需要保证供给侧有足够的车辆,线下运维可能会进行一些额外的调度工作等等,这里面就需要关注运维系统相关的流量变化。</p><p>总的来说,即<strong>技术目标</strong> = <strong>业务目标 -> 实现路径(策略、玩法) -> 找到依赖的系统 -> 明确系统的QPS</strong>。</p><p><strong>3、压测演练</strong></p><p>技术目标设定好之后,就要进行压测了,然后根据压测的结果进行调整和优化。在设定技术目标的同时,我们还要根据业务线的具体业务玩法,设定对应的应急预案,这些预案也要经过演练来验证。</p><p>因为压测演练讲得比较多,这里就不做过多展开,感兴趣的同学可以翻阅以往的文章。</p><p><strong><em><em>4、变更管控</em></strong></em></p><p>变更是导致线上故障的最大来源之一,因此大促期间,<strong>变更需要提前做好管控</strong>。根据大促的具体节奏,提前设定好相应的封网计划,包括应用发布、配置变更、运维变更等等,同时也准备好相应的应急流程,对于某些特殊情况需要变更的,做好记录,以便活动结束后进行复盘。</p><p><strong>5、内灰上线</strong></p><p>大促活动因为有时间窗口限制,所以在正式上线之后要做充分的测试,避免出现意外情况。在做好灰度发布的基础上,对于大促的活动业务逻辑,也要进行灰度验证,一般可以用内部、外灰逐步放量、扩全的方式进行。这次大促活动正式上线之前,我们采用了内灰的方案进行验证,先让公司内部同事加入到灰度白名单,去体验一下大促玩法等等。但是要<strong>注意数据的隔离,避免内灰测试中,消耗了真实的奖励等等</strong>。</p><p>6<strong>、应急值班</strong></p><p>大促开始前,要<strong>明确好信息同步机制,即大促值班纪律和规范</strong>,比如说系统核心owner必须到作战室进行值班,同时在IM中提前规范各个沟通群的名称和作用,把相应的研发、产品、业务、运营、客服都关联同学都拉到对应的沟通群。</p><p>比如说可以建一个全员沟通群,用于信息同步和相关通知。同时为了高效决策,还需要把一些有决策权的TL拉到会议室一起值班,出现问题之后快速决策,下发执行等等。</p><p><strong>小结</strong></p><p>上面主要是大促过程中的几个关键环节,看完了这些,相信大家对大促保障已经有一个整体认识了,接下来我们重点聊一下保障方案具体怎么做,如果你是大促技术PM,你会如何制定保障方案?</p><h3>三、大促保障方案详解</h3><p><img src="/img/bVc59ww" alt="image.png" title="image.png"></p><p>大促保障方案是一个整体的框架,在实际的工作中,是由大促保障组产出了这个框架,然后各个大促技术PM根据业务特点,制定出具体的保障方案,接下来大家一起评审并进行相应的压测演练验证。</p><p>为了让大家理解一致,每个模块下都有了明确的<strong>产出物要求,即具体要做什么事情</strong>。</p><p><strong>1、链路梳理</strong></p><p>大促的关键点在于流量,想要治理好流量,就需要对流量路径做到了如指掌。比如说,<strong>本次大促有哪些关键入口,这些入口对应了哪些后端系统、涉及了哪些资源,目前这些系统和资源的水位怎么样,预估哪里会成为瓶颈</strong>,是否需要提前治理等等。这些都是链路需要重点关注的地方。</p><p>在链路梳理中,也应该<strong>明确强弱依赖</strong>,比如说某个系统的下游依赖中,哪些是必不可少的强依赖,哪些是可以容忍出现故障的弱依赖,以及这些依赖的降级熔断配置、超时时间设置等等,都需要详细分析。</p><p>在分析流量路径的时候,要注意着重识别是否存在<strong>热点流量</strong>,例如产品一般在大促开始前对大量人群做push,那么用户点击push之后,跳转的<strong>落地页对应的接口可能会存在热点流量,要进行重点保障</strong>。</p><p><strong>产出物:</strong></p><ul><li>1、一张能反应当前系统实际情况的链路关系图,要能够看到流量路径、反映出强弱依赖关系。</li><li>2、目前服务之间的依赖关系的检查结果List,是否存在风险项。</li></ul><p><img src="/img/bVc59wy" alt="image.png" title="image.png"></p><p><strong>2、技术目标&容量水位</strong></p><p>容量水位分析,是为了分析当前系统是否存在资源瓶颈,有无需要提前扩容的资源等等。如果暂时无法明确,也可以先输出现状,待压测之后再确认具体情况。</p><p><strong>产出物:</strong></p><p>1、一张当前系统入口的qps表格,包括目标qps、当前实际qps、是否需要扩容等。</p><p>参考格式</p><p><img src="/img/bVc59wB" alt="image.png" title="image.png"></p><p><strong>3、监控告警</strong></p><p>无论是在常态化稳定性保障还是大促稳定性保障,监控告警的治理都是重中之重。</p><p>在大促场景中,需要特别注意两个点:</p><ul><li>1、当前各个系统的监控告警配置情况,<strong>指标覆盖是否完善和阈值设置是否合理</strong>。包括基础设施监控、中间件监控、应用层监控、流量入口监控等等。</li><li>2、与本次大促有关的一些<strong>业务监控是否完备</strong>,是否能够通过业务指标观察当前大促的流量路径。比如说业务活动的转化一般是呈漏斗型,以「一个通过发放优惠券来促进下单」的场景为例:需要有一套对应的业务大盘,能看到从<strong>优惠券曝光、用户领取、下单核销</strong> 等各个环节的情况。</li></ul><p><strong>产出物:</strong></p><ul><li>1、各项监控大盘的地址和梳理结果,监控监控的覆盖度是否完整,告警策略是否正常等。</li><li>2、监控指标check完了之后,要通过故障演练来模拟资源水位变化,看监控告警是否正常。</li></ul><p><strong><em><em>4、应急预案</em></strong></em></p><p>从以往的稳定性治理经验中可得,<strong>即使我们做了万全的准备仍然有可能出现意外情况</strong>。因此,大促保障中更是要对各种“可能出现”的意外情况,做好充分准备。比如说上面提到的链路梳理中,有些依赖可能需要手动调整,在系统压力过大的情况下需要屏蔽掉一些非核心逻辑,比如说<strong>为了保证极端情况下的用户用车,可以暂时关闭红包车检查</strong>等等。</p><p>按照大促时间轴,从“事前、事中、事后”三个维度来梳理预案,对于一些对业务可能产生的预案,要写清楚业务影响面和预期,以及提前与业务方沟通清楚。</p><ul><li>事前预案: 提前扩容、配置限流、缓存预热等、边缘业务降级等。</li><li>事中预案:即应急预案,动态配置开关等。</li><li>事后预案:大促结束后要做的预案,一般为系统缩容、恢复边缘业务等。</li></ul><p><strong>产出物:</strong>应急预案手册,可以用表格形式呈现,包括预案的类型、触发条件、执行动作、预估影响、执行人、生效速度等等。</p><p><img src="/img/bVc59wD" alt="image.png" title="image.png"></p><p><strong>5、联动机制</strong></p><p>一场大促会牵扯到多方,包括产品、业务、客服、其他关联部门等等。<strong>联动机制主要包括应急值班和信息同步机制</strong>,比如说大促进行中,出现了线上故障,在处理故障的同时,要把相关信息同步给关联方。某些情况下,需要执行预案了,这个执行预案的动作也需要同步给关联方。</p><ul><li>应急值班: 包含值班人员名单和联系方式。</li><li>信息同步机制:包含与产品/业务/客服等的沟通通道和机制。</li></ul><p><strong>产出物:</strong></p><ul><li>1、值班干系人名单,值班信息。</li><li>2、各业务应急值班群&沟通流程。</li></ul><p><strong>6、外部合作方保障</strong></p><p>想要顺利通过大促,除了内部的各种保障,也需要注意外部的一些合作方的保障。避免<strong>业务流量过大,影响了合作方的接口质量</strong>等,本次活动是否依赖外部合作方(开放API/外部HTTP交互等),对于合作方的接口质量是否有监控告警手段,例如合作方接口的rt、错误率等指标是否可观测,是否具备切换能力,例如从A合作方切换到B合作方。</p><p>提前明确我方的流量目标,与合作方进行沟通,要求合作方制定相应的保障策略,例如让合作方<strong>在大促期间尽量避免变更等操作</strong>。最终一起与合作方输出流量评估结果和应急手段。</p><p><strong>产出物:</strong></p><p>1、与合作方的评估结果表格,包含: 业务场景、评估结果、合作方值班人、应急预案等。</p><h3>四、不同团队的保障要点</h3><p>上文我们提到过,在具体保障工作中,要结合各团队的业务特点,制定出相符的保障方案。在多业务形态的公司中,就有个比较典型的情况:<strong>前台业务和中后台业务的保障要点是不一样的</strong>。比如说,以哈啰的业务场景为例,会有下面的两个特点。</p><p><strong>前台业务保障要点</strong></p><p>例如两轮、四轮、电动车、电池等等,核心的目标是保障用户体验和支撑业务流程,因为重点是<strong>保证自身和相关下游强依赖系统的稳定性</strong>。注意两个点:</p><ul><li>1、全链路: 从业务流程开始到业务流程,整个业务链条的稳定性。</li><li>2、端到端: 从APP(小程序/H5)端开始,到网关、业务系统、存储等稳定性。</li></ul><p>在整体的方案设计中,要结合业务逻辑,准备充足的应急预案。</p><p><strong>中后台业务</strong></p><p>例如 用户平台、支付平台、交易平台、地图&AI等等,在保障全链路稳定性的基础之上,还需要格外注意<strong>多个上游之间的流量叠加之后的压力</strong>。例如在平时的时候,两轮和四轮的高峰期可能重叠度不高,大促期间进行了大量的营销推广,高峰期可能会叠加;<strong>以及具备对不同上游的流量做隔离的能力</strong>,例如某个业务对平台侧服务调用量过大,平台侧应该有自我保护机制,避免系统被击垮后影响了其他业务线。</p><h3>五、事后复盘</h3><p>930大促顺利结束了,对于哈啰来说,各项业务指标也上了新的台阶。</p><p>但是对于有追求的技术人来说,大促结束之后,并不是一切都结束了,<strong>稳定性保障工作想要精益求精,就是要在日常的细节中做到位</strong>,在项目复盘中,<strong>要回顾大促期间的系统表现,与此前的预估模型、压测期间的系统表现进行对比</strong>。有没有哪些系统资源的水位出现了异常,利用率是否出现了过高或者过低的情况。要反过来思考为什么在此前的方案制定、压测演练中没能发现这些问题。进而优化后续的保障工作。</p><p>同时,也要回顾一些应急预案执行、变更破网等情况,比如预案的执行过程、执行结果是否都符合预期,有无优化余地。一些手动预案能否变成自动化预案等等。破网的变更有哪些,是否都是必要的,后续的大促中,能不能尽量提前变更,降低大促期间变更风险等等。</p><h3>写在最后</h3><p>稳定性的工作因为覆盖面比较广,事项比较大,因此大家总是觉得比较琐碎,我们要做的是<strong>从这些琐碎的事项中抽象提炼出共性的东西,对它进行归纳总结,将其形成我们自己的方法论,这样才能慢慢成长起来</strong>。这一次大促保障,不管是系统本身,还是我们的稳定性经验保障,都得到了很大的提升。但是技术是无止境的,我们也从不敢大意,仍然以谨慎的心态去做好稳定性保障。大家在日常的稳定性工作中有什么体会么?欢迎大家在留言交流!</p><p>(本文作者:孟闯)</p><p><img src="https://segmentfault.com/img/bVc0Nn0" alt="图片" title="图片"></p><blockquote>本文系哈啰技术团队出品,未经许可,不得进行商业性转载或者使用。非商业目的转载或使用本文内容,敬请注明“内容转载自哈啰技术团队”。</blockquote>
年度重磅|2022哈啰技术精选电子书下载
https://segmentfault.com/a/1190000043302216
2023-01-11T10:12:41+08:00
2023-01-11T10:12:41+08:00
哈啰技术
https://segmentfault.com/u/hellotech
0
<p>不知不觉中,兔年新春即将到来。</p><p>过去的2022年,哈啰全面升级,焕新出发,不断推动技术更新迭代。哈啰技术人也做了大量的总结和思考,并以文字等形式沉淀下来,向各位同学分享哈啰的实践探索与经验心得。</p><p>在2023年春节到来之际,我们为大家盘点过去一年的精选文章,整理制作成一本300+页,近10万字的电子书。电子书的内容覆盖前端、后端、算法、运维、质量等不同领域,每一篇都干货满满,希望对各位同学拓展技术思路有所帮助。</p><p>感谢各位同学一直以来的支持,也欢迎大家将「哈啰技术」推荐给身边感兴趣的朋友。</p><p><img src="/img/remote/1460000043302218" alt="图片" title="图片"></p><h4>获取方式</h4><p>关注「哈啰技术」微信公众号,回复关键词:2022精选,即可获取电子书的下载链接。</p><h4>福利时间</h4><p>回复关键词:抽奖,更有机会获得哈啰技术周边一份哦。</p><p>最后,祝大家在新的一年里收获满满,成长多多。如果您对哈啰技术有想说的话,也欢迎与我们交流。</p>
地图团队逆地理编码调用量优化实践
https://segmentfault.com/a/1190000043098832
2022-12-16T16:24:53+08:00
2022-12-16T16:24:53+08:00
哈啰技术
https://segmentfault.com/u/hellotech
0
<h2>背景</h2><p>逆地理编码(将经纬度转换为详细结构化的地址)调用目前是整个地图服务调用量最大的接口,业务主流程多个节点依赖逆地理服务,接口不可用会直接阻塞订单。目前高峰期高德逆地理接口的QPS(Queries Per Second 每秒查询率)经常会几倍的超掉,超限报错的请求会通过哈啰地图平台的LBS(Location Based Services 基于位置的服务)兜底返回数据。</p><p>目前的逆地理调用量日均在2-3亿左右远超现有业务体量,面对业务在业务冲单时预估的几倍调用量增长与供应商较高昂的提额价格,虽有LBS兜底但高峰期大量流量打到LBS也会极大增加服务压力,可能引发连锁反应,在冲单前优化调用量的问题保障接口稳定性迫在眉睫。</p><h2>整体方案设计</h2><p>首先是要明确每个节点的调用量是多少,针对调用量头部的节点我们想通过code review和与业务同学沟通调用节点的业务诉求,看能不能直接删除或通过其他方式进行优化,对于不好优化的,我们想通过缓存机制来减少一部分调用。</p><h3>信息补充</h3><p>目前报表只能从业务维度来拆分,并不能知道具体每个节点的调用量是多少,是哪个节点的调用量过大。所以第一步就是埋点补充调用节点参数。细致的埋点是我认为所有优化的第一步,它能明确线上究竟是什么样的,才能更好的针对性优化,并在后续的优化回收,监控等过程中提供数据支撑。拆分后报表示意如下:<br><img src="/img/remote/1460000043098834" alt="图片" title="图片"></p><h3>逐个击破</h3><p>基于第一步的拆分,我们已经能知道调用量较高的节点都是哪些,针对这些节点我们将结合业务使用诉求与代码细化场景,寻找问题和优化空间进行了逐个优化。这步优化总结下来有两部分:</p><h4>当前节点并不需要调用</h4><p>因信息不对称导致的业务场景使用不当,如:<br>1.业务页面有监控到位置更新就用当前位置触发逆地理的逻辑,实际高德定位组件内部会触发一次当前位置的逆地理调用并返回给业务,业务并不需要额外调用。<br>2.地图缩放时中心点并没发生变化,不需要请求逆地理。</p><h4>优化调用节点减少调用</h4><p>结合业务使用场景上下文,尽可能的减少调用,如:<br>1.业务有一个功能会检测用户如果距离当前地图中心点超过50m,就把用户当前位置变成地图中心位置并会触发逆地理的请求,但功能上线后由于在APP首页生效无法释放导致整个APP生命周期一直在触发位置跟随功能造成不必要的调用,类似问题的解决方法是对功能限制作用域,感知页面与APP的生命周期,如APP不在前台或不在当前页面就关闭功能。<br>2.POI(Point Of Interest 兴趣点)搜索后会触发的上车点检索并进行逆地理的调用,上车点吸附成功时需要的逆地理数据POI数据中均包含,上车点吸附失败时逻辑是使用POI搜索的数据也不需要逆地理调用,所以我们省去了POI搜索后触发的上车点检索后的逆地理调用,均用POI数据填充逆地理的数据。</p><h4>阶段产出</h4><p>这部分优化结束后我们APP调用量级从日均2-3亿降到了3-4千万左右。</p><h3>使用缓存</h3><p>我们希望能通过缓存来提高数据的使用率,用内存+磁盘缓存的方式持久化缓存数据以提高命中率。<br>使用逆地理接口请求的参数(经纬度+请求半径)生成key,将请求结果存入到内存缓存+磁盘缓存中,获取缓存时先从内存中查找,没有再从磁盘中查找。</p><h4>经纬度的聚合问题</h4><p>由于定位本身就会因为各种原因发生偏差(基站定位?信号遮挡?)或者用户在短距离内移动,直接以经纬度生成key会严重影响缓存命中率,我们希望能聚合一定范围内的经纬度,这样可以有效提升缓存命中率。</p><p><strong>GeoHash算法</strong><br>GeoHash是一种地址编码方法,他能够把二维的空间经纬度数据编码成一个字符串。</p><p><strong>算法思想</strong><br>GeoHash表示的并不是一个点,而是一个矩形区域,编码越长,表示的范围越小,位置也越精确,GeoHash编码的前缀可以表示更大的区域。例如wx4g0ec1,它的前缀wx4g0e表示包含编码wx4g0ec1在内的更大范围。</p><p><strong>算法原理</strong><br>经度范围是东经180到西经180,纬度范围是南纬90到北纬90,我们设定西经为负,南纬为负,所以地球上的经度范围就是[-180, 180],纬度范围就是[-90,90]。如果以本初子午线、赤道为界,地球可以分成4个部分。</p><p>如果纬度范围[-90°, 0°)用二进制0代表,(0°, 90°]用二进制1代表,经度范围[-180°, 0°)用二进制0代表,(0°, 180°]用二进制1代表,那么地球可以分成如下4个部分:</p><p><img src="/img/remote/1460000043098835" alt="图片" title="图片"></p><p>会生成类似于Z的曲线,如果在小块范围内递归对半划分呢?</p><p><img src="/img/remote/1460000043098836" alt="图片" title="图片"></p><p>当我们递归的将各个块分解成更小的子块时,编码的顺序是自相似的(分形),每一个子快也形成Z曲线,这种类型的曲线被称为 Peano 空间填充曲线, Peano 空间填充曲线有突变性问题(有些编码相邻但距离却相差很远,比如0111与1000,编码是相邻的,但距离相差很大)和临界问题(与相同GeoHash编码的点的距离有可能大于临界不同GeoHash编码的点的距离 如上图红点蓝点的距离是远大于红点与绿点之间的距离),评估后对于我们的使用场景可接受。</p><p>编码长度就是对方块的划分次数。执行逻辑:</p><ul><li>根据设定的编码长度对当前经纬度分别进行划分,得到两组二进制串(10101、01010)后以偶数位放经度,奇数位放纬度的方式合并成一个二进制串(1001100110)</li><li>将二进制串划分每5位一组,不足5位补0(10011、00110)</li><li>将各组的5位二进制串转成十进制,5bits对应着10进制的数值为0-31(19、6)</li><li>用0-9、b-z(去掉a、i、l、o)这32个字母进行Base32编码,即对照下标将其转换为字符串(m、6)</li></ul><p><img src="/img/remote/1460000043098837" alt="图片" title="图片"></p><ul><li>最后拼在一起得到的字符串就是GeoHash编码(m6)</li></ul><p><strong>编码长度对应的偏差范围</strong></p><p><img src="/img/remote/1460000043098838" alt="图片" title="图片"></p><p>目前缓存设置的编码长度为GeoHash9(5m左右误差)。</p><h4>缓存淘汰机制</h4><p>采用业界主流的LRU算法策略(Least Recently Used,即最近最少使用,是一种常用的页面置换算法,选择最近最久未使用的页面予以淘汰)。</p><p><strong>算法思想</strong><br>如果数据最近被访问过,那么将来被访问的几率也更高。原理解析新数据插入到链表头部;每当缓存命中(即缓存数据被访问),则将数据移到链表头部;当链表满的时候,将链表尾部的数据丢弃。</p><p><img src="/img/remote/1460000043098839" alt="图片" title="图片"></p><h4>其他淘汰机制</h4><p>因为高德逆地理数据偶尔也会有badcase需要高德更新数据fix,我们希望数据除了LRU被淘汰以外还能有其他维度的机制来更新数据:</p><ul><li>时间维度:我们限制只使用2天内的数据,如超过则淘汰数据,重新请求并缓存</li><li>访问次数维度:我们限制数据使用10次后,会主动淘汰数据,重新请求并缓存</li></ul><h4>阶段产出</h4><p>冲单当日数据回收缓存命中占比iOS为26% 安卓为29.4%。</p><h3>缓存算法优化</h3><h4>缓存命中分析</h4><p>目前日均的缓存命中率在25%左右,跟我们的预期相比还是会低一些,原因分析如下:目前因为避免占用大量内存(根据附近POI多少不同一条数据在2-8kb之间)造成OOM问题(Out Of Mana法力耗尽 Out Of Memory内存溢出 占用内存过大会被系统强制杀死 造成闪退),规定了缓存最大数量为50个,那是否调大缓存个数就能提高命中率呢?比如提高到200-300个,我们认为也不能提升太高,而且会增加OOM的风险。从我们的实现与场景分析下:</p><p><strong>1.LRU算法分析</strong><br>优点:LRU算法实现简单,并且在大量频繁访问热点页面时十分高效。<br>缺点:由于LRU的机制,遇到偶发性或周期性的批量操作会导致LRU的命中率急剧下降,缓存污染情况比较严重。</p><p><strong>2.结合场景分析</strong><br>根据我们的出行场景,我们希望命中缓存的数据分为两部分:一是短时间内的重复请求,这部分目前已经可以满足;二是根据用户的使用习惯缓存下家或公司学校等常用地附近的逆地理信息,这部分权重较高比较容易命中缓存,但用户在行程过程中会有大量数据写入造成“缓存污染”。所以,我们需要一种增加权重机制的缓存淘汰算法来解决行程过程中的缓存污染。</p><h4>LRU-K算法</h4><p><strong>算法思想</strong><br>LRU-K中的K,其实是指最近访问页面的次数,LRU算法其实就是LRU-1,但是因为仅访问1次就能替代别人,可能会造成“缓存污染”的问题,因此提出了LRU-K的概念,其核心思想就是将访问一次就能替代的“1”提升为"K"。</p><p><strong>原理解析</strong><br>LRU-K算法需要维护两个队列:历史队列和缓存队列。</p><p>历史队列保存着缓存的对象(内存中),当对象访问次数达到了K次,该对象出栈,并保存至缓存队列;若尚未达到K次则继续保存,直至历史队列也满了,那就根据一定的缓存策略(FIFO、LRU、LFU)进行淘汰。</p><p>缓存队列则是保存已经访问K次的对象,当该队列满了之后,则淘汰最后一个对象,也就是第K次访问距离现在最久的那个对象。</p><p><img src="/img/remote/1460000043098840" alt="图片" title="图片"></p><p>对应到我们的实现里就是历史队列的数据获取超过K次后才会加入到内存缓存+磁盘缓存进行持久化保存,而历史队列本身也在充当内存缓存的角色不会有重复的存储,且由于有了历史队列进行权重过滤,会大大减少数据库写入,减少整体性能消耗。下图为选用的磁盘缓存(YYCache)的读写性能图。</p><p><img src="/img/remote/1460000043098841" alt="图片" title="图片"></p><p>这部分正在进行中,预计能提高缓存10-20%的命中率。</p><h3>容灾方案</h3><p>如果在上述优化后还出现高德QPS超限降级到自研实现且自研实现QPS也超限的情况,此时继续调用也没有任何意义了,我们希望能通过降低自身的请求的方式来减轻当前服务器的压力(是一种较无私的方案)。</p><p>满足上述条件后会触发端上的容灾策略,会去以GeoHash7(80m左右误差)生成key获取缓存,如获取不到则停止服务调用2s直接报错以减轻当前服务器的压力。</p><p><img src="/img/remote/1460000043098842" alt="图片" title="图片"></p><h3>防裂化</h3><p>接入到MapService(地图团队维护的新组建)后,会有独立的报表与LBS的流量监控,对调用量超过原QPS的接入方会钉钉发出告警通知。</p><p><img src="/img/remote/1460000043098843" alt="图片" title="图片"></p><p>并可通过LBSAdmin平台(LBS管理平台)对流量异常的节点进行动态降级,无需发版就能恢复线上异常节点对其他业务的影响。</p><p><img src="/img/remote/1460000043098844" alt="图片" title="图片"></p><p>整体流程如下:</p><p><img src="/img/remote/1460000043098845" alt="图片" title="图片"></p><h2>收益</h2><p>地图平台移动端通过对数据来源的细化、与业务同学深入交流分析、合理使用算法能力、与地图后端能力深度结合不断进行优化升级,完整的支撑了公司冲单的活动,日均调用从2-3亿降到了2-3千万左右。</p><h2>总结</h2><p>最后总结出我们认为在任何优化中都很重要的点:<br>1.保持敬畏:优化前要非常明确这段代码的作用与影响面,并且每个优化都要可灰度,可恢复(有些代码怎么看都是多余的,删除了看似也没啥影响,一发布就出现线上事故)稳扎稳打不要适得其反。<br>2.白盒优化:还原线上运行的真实样貌,要明确知道要优化哪里,优化后的真实效果,不能只靠YY。<br>3.做长远建设:长远建设会成为后续持续优化打下结实的基础。<br>4.防裂化:不要忽视防裂化建设,否则后面将变成“一年一度整活大赛”。后续我们将会继续针对调用量、稳定性、业务隔离、多数据源等方向做更多有趣的尝试。</p><p>作者简介:<br>陈东冉、刘大白、任赛龙等,均来自哈啰人工智能与地图团队-地图平台。</p><p>招聘信息:<br>哈啰地图团队诚招高级、资深工程师,Base上海、杭州。我们致力于为哈啰提供高性能、高可用、高体验的端到端出行解决方案服务,涵盖复杂架构设计、深度性能优化、算法支撑赋能等技术领域,端侧地图解决方案,欢迎有兴趣的同学投送简历至:<a href="https://link.segmentfault.com/?enc=l5NcIqfRmjTc8%2FqA4yLxJA%3D%3D.a196TuAZrGGYlcJipwarxe6G1V2SXKluBmWYRZdrmDc%3D" rel="nofollow">https://careers.hellobike.com/</a>。</p><p><img src="https://segmentfault.com/img/bVc0Nn0" alt="图片" title="图片"></p>
哈啰App首页千万DAU的容器动态化方案 - 乐高系统
https://segmentfault.com/a/1190000042916395
2022-11-28T15:31:35+08:00
2022-11-28T15:31:35+08:00
哈啰技术
https://segmentfault.com/u/hellotech
1
<h2>前言</h2><p>你是否为项目核心页面缺少标准化的开发规范和流程、代码冗余耦合严重而无从下手?<br>你是否为项目中多人或多个团队跨团队协同开发一个页面功能而烦恼?<br>你是否为项目复杂页面缺少端到端动态化编排配置运营的手段、开发/发布周期和流程过长而耗时耗力?<br>你是否为项目中多团队高频迭代开发核心页面的质量和稳定性后知后觉而焦虑?</p><h2>背景</h2><p>在哈啰的快速发展和业务演进过程中,从两轮出行到四轮出行再到基于出行的普惠生活服务平台,APP DAU已突破1000+w,细分的业务场景也越来越多,孵化了数十个业务BU。从出行用车到生活服务,有两轮的单车、助力车,四轮的打车、顺风车、送货,以及租车、智能电动车、火车票、换电、好物、酒店、相亲、宠物、游戏等等多类覆盖出行和生活的业务,哈啰可以说是目前出行生活平台领域业务复杂度较高的应用之一。</p><p>我们平台运营团队负责APP启动到首页等多个哈啰重要的用户入口,承担着流量分发平台的作用,提供平台能力支持业务线发展。平台运营本身也是一个平台业务,除了自身的业务外,对外的角色也很复杂,同时还承担着新业务发展平台的角色,这意味着想要支持好哈啰平台业务的发展是一件非常有挑战的事情。</p><h2>演进思路</h2><p><img src="/img/remote/1460000042916397" alt="图片" title="图片"></p><p>以哈啰APP首页为例,在平台化转型业务快速增长发展初期:平台首页团队只有两三个人<strong>对接十多个业务线</strong>,每个业务都有<strong>特定的场景和诉求</strong>,并且部分业务场景除了需要用户运营外还存在着互斥或优先级的关系,需要<strong>一定的策略运营能力</strong>。业务方急需在<strong>短期内快速更新迭代</strong>来验证业务方向。因此首页随着多个团队的共同参与<strong>开发复杂度和协作成本是极高</strong>的,同时随着增加人员带来<strong>迭代边际效应越来越低</strong>。由于不同团队/人员缺少统一、标准的开发规范和风险意识,<strong>体验和稳定性难以保障</strong>,严重影响用户增长和业务发展。</p><p>所以我们面临的问题是:</p><ul><li>首页等业务核心页面存在单页面多团队协同开发,承载多业务形态的诉求。</li><li>首页等业务核心页面希望可以快速迭代来验证业务方向。</li><li>首页等核心页面需要有一定的策略运营能力来为用户提供所需的业务服务。</li><li>首页等业务核心页面需要有良好的用户体验和稳定性保障。</li></ul><p>基于现状问题和未来演进方向,我们按照端容器承载、模块化拆分的基本思路,结合基础能力抽象成框架、对接口和数据结构进行定义等,重新进行架构分层和方案设计,来满足多端复用、平台赋能、多团队协作、需求动态变更/发布等多方面诉求。</p><h2>解决方案 - 乐高系统</h2><p><img src="/img/remote/1460000042916398" alt="图片" title="图片"></p><p><img src="/img/remote/1460000042916399" alt="图片" title="图片"></p><p>根据上述的问题和思路,我们系统的建设有两个目标:</p><ul><li>对于研发侧:需要支撑核心场景下多团队协同开发的<strong>持续交付效率</strong>和保障复杂场景下的<strong>稳定性&体验</strong>,来支撑业务发展。</li><li>对于业务侧:需要可以在APP快速搭建可靠的高性能原生页面,进行<strong>动态编排和丰富的运营能力</strong>,需求迭代动态线上变更快上快下,<strong>快速有效的验证业务方向</strong>。</li></ul><p>基于基本思路和目标确定了三个后续演进的方向:标准化的前后端开发框架和体验&稳定性保障,动态化的发布和线上运营能力,系统化的从前到后端到端解决方案。</p><p>整体链路和系统建设上的目标,希望技术演进为业务赋能支持业务<strong>更快更好发展</strong>。</p><h3>设计思路</h3><p>那我们明确一下什么是乐高系统?要解决什么具体问题?</p><p>乐高系统是基于“哈啰APP首页方案”演进过程中积累的经验,孵化建设的一套适合哈啰的端到端动态化运营发布系统。来解决业务发展过程中运营能力欠缺或重复建设、发布周期长、业务验证效率低、研发投入和维护成本高、以及保障核心页面的体验&稳定性等诉求。</p><p><img src="/img/remote/1460000042916400" alt="图片" title="图片"></p><p>乐高的应用场景是哈啰APP首页等一级页面和业务核心页面,也可以应用到其他业务矩阵内的宿主APP。是面向APP的页面动态搭建和运营发布平台,帮助业务提升产研效率。面向对象有三个角色,对应三个子系统的产品能力,分别对应不同的能力边界:</p><p>1.<strong>在搭建平台侧</strong>:*面向产品运营的乐高运营后台</p><ul><li>面向产品运营提供完整的权限审批和发布流程,环境可隔离,支持测试、预发、灰度、发布。</li><li>提供页面容不同粒度模块/组件/样式/内容的动态搭建编排和发布能力。实现页面搭建、组件管理、协议编排(有序化/互斥/灰度/AB/标签)等能力,与投放平台、伽利略实验平台和数据平台打通;</li><li>提供完善和丰富的运营能力,千人千面。</li></ul><p>2.<strong>在聚合服务侧</strong>:*面向后端研发的聚合服务配置平台</p><ul><li>搭建元数据配置化后台,进行数据源/样式配置管理、参数管理映射和动态脚本能力,与数据模型进行绑定,数据模型与组件进行关联。</li><li>实现聚合服务BFF层,结合元数据配置后台进行可视化配置化驱动数据编排,以及发布流程和容灾机制。</li><li>深度结合整体架构设计和框架特性进行性能&稳定性建设,保障服务侧可灰度、可观测、可应急。</li></ul><p>3.<strong>在移动端框架侧</strong>:*面向移动端研发的乐高容器动态化框架</p><ul><li>借鉴适配器设计模式和依赖倒置原则,提供注解的方式进行注册依赖解耦,用模块仓库进行统一管理。设计通用的接口协议Adapter Protocol,面向标准的抽象协议接口开发,业务逻辑和依赖组件化隔离,完全支持热拔插。</li><li>抽象通用的布局数据、状态管理、生命周期及事件总线的能力,沉淀通用能力和组件,减少重复的代码开发,提升研发效率。</li><li>在页面布局和状态管理方面,与列表容器深度结合,实现高效的页面渲染、数据驱动的页面刷新能力;稳定可靠的高性能前端流式布局和完整数据生命周期支持框架。</li><li>结合公司内部自研的“悟空DSL”动态化渲染方案,实现页面局部动态化渲染,打通页面的最后一个编排粒度,结合乐高后台实现模块功能动态发布上线。</li><li>深度结合整体架构设计和框架特性进行性能&稳定性建设,保障端侧可灰度、可观测、可应急。</li></ul><h3>系统架构</h3><p><img src="/img/remote/1460000042916401" alt="图片" title="图片"></p><p>乐高系统从架构上整体分三部分:</p><ul><li>面向产品运营的配置化后台,进行线上编排和运营。作为业务的统一输入口,配置驱动业务的有效验证。</li><li>面向服务端的聚合服务与元数据配置后台。作为配置和数据驱动的中间层,打通运营后台和应用层的端到端流程。同时通过配置化提升服务端的研发效率,支持聚合服务的容灾降级和观测告警能力。</li><li>面向移动端的乐高容器框架。作为核心面向用户的应用层框架,为用户动态的呈现丰富的业务服务和所需的业务功能。支持端侧的性能&稳定性监控告警和灰度降级能力。</li></ul><h3>移动端-乐高容器</h3><p>乐高容器化框架的思路是<strong>把一个页面用模块化的方式拆分</strong>,按照<strong>抽象定义好的规则/逻辑进行动态编排</strong>,页面容器去承载这些抽象的功能模块,赋予其独立的加载渲染生命周期、数据分发、事件总线和消息通信能力。通过框架协议将模块标准化后统一管理,在过程中沉淀出通用的组件能力,提高复用减少重复建设。</p><p><img src="/img/bVc4eEG" alt="image.png" title="image.png"></p><p>结合运营后台能力和聚合服务的配置化能力的搭建和打通,<strong>将复杂页面的搭建抽象成结构化数据</strong>(数据 + 容器处理 + 模块树 + 渲染节点),由结构化数据驱动模块/组件/样式/内容模版的拼装。使页面动起来,让用户看到需要的内容(千人千面),<strong>让业务只聚焦于自身模块</strong>的业务功能实现和动态发布,快速验证方向。</p><p>1.动态卡片 - 悟空DSL<br><img src="/img/remote/1460000042916402" alt="图片" title="图片"></p><p>悟空DSL是哈啰内部研发的一套动态化渲染技术方案。悟空作为容器动态化的一个原子能力,补足了乐高容器页面动态化最后一环能力,支持在容器模块内进行弱交互的平铺UI的开发和绘制,结合乐高搭建的动态运营后台和服务配置化数据绑定打通,达到UI层的动态渲染和发布能力。</p><p>2.组件沉淀<br><img src="/img/remote/1460000042916403" alt="图片" title="图片"></p><p>组件模块就是基于业务迭代过程中抽象出的通用模块,持续沉淀到组件池,定义好标准的数据结构,与后台打通,通过表单配置和数据驱动页面上组件的变化呈现。最大程度提升基础能力的复用和减少端到端链路重复建设的成本,业务拿来即用。</p><h3>服务端 - 配置化聚合服务</h3><p><img src="/img/remote/1460000042916404" alt="图片" title="图片"></p><p>按需搭建和C端交互需要的各个模块、组件、数据源,通过配置化的方式,降低服务端针对每次不同的数据聚合需要重复开发、测试、发版的次数,只需要在页面中可视化配置即可完成,提升服务端的研发效率。</p><p>聚合服务针对下游数据源的服务等级、支撑的QPS、稳定性保障要求等提供按需配置的保障措施,比如对下游服务的限流、熔断、数据的的资源隔离等能力,减少聚合服务接入方在稳定性上的投入成本。</p><h3>稳定性建设</h3><p><img src="/img/remote/1460000042916405" alt="图片" title="图片"></p><p>乐高系统整体链路稳定性建设支持可观测、可灰度、可降级、可告警的。基于Grafana搭建的大盘数据和技术指标看板,基于UItron搭建的异常告警看板并与钉钉机器人打通,及时触达。基于前后端链路的串联打通,支持从聚合服务到端侧框架的容灾和兜底降级能力。</p><h2>流程</h2><h3>发布流程</h3><p><img src="/img/remote/1460000042916406" alt="图片" title="图片"></p><p>1.产运创建页面,进行模块、策略、内容的搭建和编排,将待实现的模块功能通过工作流指派给对应的研发。<br>2.前后端研发进行模块功能配置或定制开发,然后根据任务进行模块-组件-数据模型关联,完成后通知验收。<br>3.测试进行功能验证,产品进行验收后进入预发布阶段,线上动态发布需要选择对应的审批人进行把关。<br>4.对应负责人审批后,需求功能上线开启阶段性灰度比例,逐步放量观测。<br>5.过程中会有监控大盘实时进行业务和技术指标监控。<br>6.观测到异常后立即进行本地自动降级或版本回滚并通过钉钉告警到对应的产品研发进行跟进。<br>7.一段时间内观测无异常后进行自动/手动全量,后产品运营进行数据回收。<br>8.完成整个灰度发布流程。</p><h3>研发流程 - 移动端</h3><p><img src="/img/bVc4eFE" alt="image.png" title="image.png"></p><p>原流程上每个功能的开发上线都需要经过双方甚至多方的共同介入,进行方案沟通和联调,换一个对接人换一种方案,整个协同过程是人为驱动和把控,在这种单页面多团队的场景下,耗时耗力。</p><p>乐高流程在页面配置好后,按照框架规范接入或者直接复用已有的模块功能,由配置数据驱动,研发和发布流程上有三种场景:<br>1.静态功能模块:模块组件库里没有或者无法抽象和动态开发的模块,正常按照框架接口开发完成后,配合后台进行配置编排和策略运营。*该情况一下会考虑抽出一些可以复用的模块组件来沉淀到组件池。<br>2.动态功能模块:乐高结合悟空DSL的局部样式动态渲染能力,支持局部的动态化开发和发布。只需要一端在远端进行UI样式布局的编写,在聚合服务配置平台配合字段,再进行发布即可。研发效率和发布回收效率都可以提升之前的一倍以上。<br>3.组件功能模块:通过业务诉求迭代过程中抽象沉淀出来通用功能模块(如:宫格、banner、瀑布流...),后台产运进行配置即可,无需研发重复投入和发布。复用后的提效效果最高。</p><h3>研发流程 - 服务端</h3><p><img src="/img/remote/1460000042916407" alt="图片" title="图片"></p><p>以前是“一杆子捅到底”的开发模式,每个展示场景的搭建需要经历过从接口的沟通到API的开发整个过程,基于新的聚合服务配置化平台的方案后,统自动具备多层复用及可视化、配置化能力。聚合服务的元数据开发和使用会有三个场景:<br>1.场景一:最优情况拿来即用,此时取数功能和展示功能都已经被沉淀下来,研发同学需要做的只是创建查询方案,基于运营平台按需选择需要的展示单元。<br>2.场景二:取数单元可以复用,在展示上有字段差异,此时可能没有展示功能,但是通过平台查看到,数据源已经接入过,只需要基于现有的数据源编写一段加工逻辑即可。<br>3.场景三:纯新接入的场景,在初期方案刚开始落地的时候比较常见,这个时候聚合服务平台还缺少相关的元数据沉淀,只需要按照标准规范将数据源接入进来,然后编写加工逻辑片段即可,之后这些能力是可以被持续复用的。</p><h3>前后流程提效对比</h3><p><img src="/img/bVc4eFK" alt="image.png" title="image.png"></p><h2>现状</h2><p>乐高系统是基于哈啰业务发展过程中实际面临的问题,搭建的端到端页面动态化发布运营解决方案。做为App三大容器解决方案之一(Web容器,小程序,乐高容器),前后端能力打通,为业务赋能,帮助产研提效、快速验证业务方向。</p><p>目前乐高已在“哈啰APP首页"、“搜索”、“钱包”等多个核心场景落地应用。并支持数次迭代功能动态发布,支持70%以上的模块功能迭代使用动态化开发或者组件复用,提升研发效率,减少维护成本。承载数千万DAU、线上运行近百个功能模块、每天数亿次加载渲染的稳定性和体验。</p><p>后续计划支持多个业务核心场景应用,帮助研发标准化搭建页面,帮助产品运营进行整个页面的动态编排。</p><p><img src="/img/remote/1460000042916408" alt="图片" title="图片"></p><p><img src="/img/remote/1460000042916409" alt="图片" title="图片"></p><h2>展望</h2><p><img src="/img/remote/1460000042916410" alt="图片" title="图片"></p><p>作者简介:刘欢,哈啰移动端开发专家,2018年入职哈啰,主导哈啰App首页架构演进和性能优化,负责平台运营客户端团队相关工作。</p><p><img src="https://segmentfault.com/img/bVc0Nn0" alt="图片" title="图片"></p><blockquote>本文系哈啰技术团队出品,未经许可,不得进行商业性转载或者使用。非商业目的转载或使用本文内容,敬请注明“内容转载自哈啰技术团队”。</blockquote>
如何做一场高质量的复盘
https://segmentfault.com/a/1190000042861409
2022-11-22T15:59:25+08:00
2022-11-22T15:59:25+08:00
哈啰技术
https://segmentfault.com/u/hellotech
0
<h2>正视故障和复盘的意义</h2><h3>故障也有积极意义</h3><p>在复杂系统中,故障是必然的,无法彻底避免。从定性的角度来看,并非所有的故障都是坏事,有些故障是有正面意义的,比如说通过一个线上的小故障发现了一个大隐患,或者是某次故障中相关人员的意识和应急预案都很到位,但是由于故障的原因非常特殊最后仍然造成了较大的影响等等,类似这样的故障都要找出其中的亮点。</p><p>所以,我们要用辩证的眼光去看待,避免大家“闻故障色变“。为了往这方面引导,我们在规章制度方面也做了很多设定,因此在我们的故障管理制度上,我们也是鼓励快速恢复(对于快速恢复的故障定级比较低)、鼓励通过演练发现更多的线上问题(对于由于演练导致的故障有一定的豁免权)等等。但是,大家也应该充分意识到我们对故障的理念:即偶尔的系统失效是可以容忍的,人为的犯错是要严肃对待的,比如说不符合高可用规范的系统设计模式、强弱依赖设计不合理、由于人员意识不到位带来的故障处理时间较长、值班人员未及时接通oncall、由于对线上系统不够重视带来的变更隐患、不遵守变更三板斧规范等等。</p><h3>复盘的3个目的</h3><p>复盘的目的是为了总结和改进,要充分利用好每一次故障的机会,从中汲取教训进行学习,提升我们的经验,完善系统的设计,我们希望达到三个目的:</p><ul><li>找到根因,从根本上进行优化和改进,给他人带来参考,未雨绸缪。</li><li>找到降低故障发生概率的方法 - 增大MTBF。</li><li>找到让业务快速恢复的方法 - 缩短MTTR。</li></ul><h3>系统和组织都要高可用</h3><p>每一次的线上故障,也是实战练兵的好机会,除开系统本身的高可用,我们的组织也应该是高可用的,我们经常说好的系统架构是具有韧性的,那么好的团队组织也应该是反脆弱的。所以复盘的过程中,除了找系统本身的问题,还要找工具的问题、流程机制的问题、管理的问题等等。这样,我们才能由点及面的系统化地解决问题,即治标又治本。</p><h2>复盘的整体过程</h2><p><img src="/img/bVc30k7" alt="image.png" title="image.png"></p><h2>复盘前的准备</h2><h3>故障参会方</h3><ul><li>直接原因方、关联(受影响)方必须全部参与,在复盘文档中记录参会人员名单。</li><li>时间线提前梳理清楚,做了哪些操作,产生了什么结果等,先与相关人员提前梳理清楚,关键信息通过截图等进行佐证。</li></ul><h3>复盘owner</h3><p>每个复盘会议,都必须有唯一的复盘owner,故障的复盘owner要主动引导大家,推动复盘进度,避免出现一些无意义的指责、与故障无关的发散讨论等等。</p><h3>理念宣导</h3><ul><li>提前声明:摆正心态,避免甩锅、批评<br>故障复盘的目的是为了讨论问题,找出改进方案避免再次踩坑。最重要的是要敞开心扉,无所顾虑的暴露问题。会议开始前事先声明复盘讨论的主题、目的。要尽量对事不对人,避免形成对某一方的批评会。</li><li>营造良好的复盘氛围<br>诚实:基于事实,勇于承认自己的问题;<br>开放:对事不对人,在尊重他人的前提下,每个人都可以充分分享自己的观点与看法;<br>担当:每个团队或个人先从自身找问题,主动提出自己需要改进的地方。</li></ul><h2>复盘的关键环节</h2><h3>故障背景概述</h3><p>故障的背景要解释清楚本次故障复盘的背景,即发生了什么故障,影响了什么业务(产品)等故障的基本情况。在复盘文档中,可以通过结构化的语言进行表达。例如:“x月x日xx时,xxx系统出现异常,导致了xxx,影响了xxx业务,表象为用户无法正常下单,点击下单按钮出现网络开小差,出现了大量客诉等等”。故障背景的意义在于让别人第一眼了解清楚这个复盘的来龙去脉,根因可以不用写太多,下面会有根因环节。</p><h3>对齐故障影响范围</h3><p>讲清楚本次故障的影响范围,包括影响时间段、影响的业务(产品)线、影响的系统(服务)、订单量、用户量、客诉量,以及有无产生资损等等。故障时间线回放提升系统可靠性的两个关键手段:降低故障发生概率和缩短故障持续时间。回放故障的时间线,即先从旁观者的角度来理一遍故障过程,是为了思考如何缩短故障持续时间(MTTR),MTTR即故障的平均修复时间,我们对MTTR其进行拆解,得到如下几个时间段:MTTR = MTTI + MTTK + MTTF + MTTV。</p><ul><li>Mean Time To Identify (MTTI): 从故障开始到应急响应介入的时间,一般是考察监控告警、人员值班oncall的合理性。</li><li>Mean Time To Know (MTTK):从应急响应介入到故障定位的时间,主要考察根因分析、可观测性等工具的能力。</li><li>Mean Time To Fix (MTTF): 从故障定位到故障恢复的时间,主要考察应急预案、快恢体系的能力。</li><li>Mean Time To Verify (MTTV):从故障恢复之后到确认故障已经解决的时间,一般通过用户反馈、自动化测试等确认恢复。</li></ul><p><img src="/img/remote/1460000042861411" alt="图片" title="图片"></p><p>因此在回放时间线的过程中,也要注意对以下几个关键时间点进行识别,然后逐个沟通讨论如何缩短其中的每一个环节耗时。</p><p>需要注意提前识别出来的关键时间点:</p><ul><li>故障引入时间点: 即这个故障实际上是从什么时候开始的,可能是某次变更发布/线上操作/其他等。</li><li>业务指标变化时间点: 业务指标开始下跌、开始恢复等。</li><li>监控告警发出时间点: 即监控是从什么发现异常的,告警什么时候发出的。告警的级别、接收人是否响应超时等相关信息都要记录进来。</li><li>人员介入响应时间点: 故障对应的系统值班owner是从什么时候开始响应的。</li><li>异常定位时间点: 即定位到故障的异常点,注意:故障处理过程中的根因定位,并非是最底层的根本原因,而是指初步确认了故障的异常点,可以进行下一步的应急止血动作。</li><li>关键操作时间点:是否做了一些应急预案,包括重启、恢复、止血、高可用配置等。还需要写清楚每个操作的结果,即每个操作之后,报错面有无缩小、系统资源水位有无变化等。</li><li>确认故障恢复时间点: 通过测试验证或者观测业务指标、系统日志等确认系统已经恢复。</li></ul><h3>深挖根因</h3><p>一般情况下,故障是由两类原因引起的,包括直接(诱发)原因和根本原因,也就是所谓的诱因和根因。</p><p>因此在复盘过程中,既要明确诱因,更要深挖根因。比如说,某个业务系统由A/B/C 3个服务组成,依赖关系依次是A依赖B、B依赖C,某次开发同学修改了线上C服务的一个配置,使用了错误的格式,导致了整个业务系统不可用。那么在原因分析过程中,把配置文件修改为错误的格式这个动作肯定是直接原因,但是也要注意,B服务对C服务的依赖关系是强依赖么?如果C服务出现异常的情况下,B服务是否要进行兜底?等等。</p><p>可以基于5why分析法深挖根因,多问几个为什么,层层递进,比如说这样的一个场景: 线上系统运行过程中,某个ES节点突然抖动,RT时间明显变长,95线由200ms升至800ms,然后引发了上游业务异常。</p><p>那么在分析原因的时候,要问以下几个问题:</p><ul><li>为什么ES会抖动?</li><li>ES的可用性标准是什么?</li><li>ES抖动之后,有出现告警吗?相关人员有第一时间介入处理吗?</li><li>ES抖动之后,上游直接使用它的服务有兜底措施吗?是否为强依赖?</li><li>对于这个业务场景来说,ES的直接上游系统是这条链路的核心依赖吗,从整个链路上有无兜底机制?</li></ul><p>要层层递进深挖根因,千万不要浅尝辄止,那样可能会错过真正的改进事项。从以往的故障来看,很多问题背后都是系统设计的问题,这样的问题挖得越深,我们的系统可用性才会越强,才能慢慢朝我们理想中的高可用架构前进。</p><h3>改进项汇总</h3><p>把时间线和根因分别确认清楚之后,就能推导出我们对于本次故障复盘的改进事项了。在梳理改进事项的时候,除了与故障相关系统的改进项之外,还需要从整个故障处理过程来看,在故障的各个环节中有无需要优化改进的地方。</p><p>比如说某个故障是靠人工(用户投诉)发现的,那么要考虑下这个业务的监控告警是否完善,是否能够降低故障触达时间;比如说某个故障的告警发出之后,迟迟没有人响应,那么要从管理制度来看,对于应急值班政策的执行是否到位;比如某个故障的排查过程中,定位比较苦难,很多地方要靠人工去梳理很多信息,那么要考虑相应的排障工具是否好用、应急预案机制是否完善等等。</p><p>还有很多的问题,大家可以参考上面的MTTR分解环节和故障根因分解环节,自己展开思考下,这也是上面说要深挖根因和详细分析时间线的目的,这样我们才能不浪费每一次故障的机会。</p><p>在记录改进项的时候,可以考虑结合SMART原则来设计改进项:</p><ul><li>S - 必须是具体的(Specific),改进项必须是可以落地的,不要泛泛而谈,例如”优化系统设计“这类就属于反例。重新设计A系统对B系统的依赖关系,使其能够对异常进行兜底,这种就属于具体的。</li><li>M - 必须是可以衡量的(Measurable),即改进项是可以评估的,比如说通过故障演练来检验依赖关系的有效性。</li><li>A - 必须是可以达到的(Attainable),在当前的技术环境下,这个改进项是可行的,不要写未来太远的无法达到的事情。</li><li>R - 与其他目标具有一定的相关性(Relevant),可以理解与本次故障中其他改进项有关联性。</li><li>T - 必须具有明确的截止期限(Time-bound),要写清楚改进项的截止时间,在到期之后进行验收。</li></ul><p>最后,改进事项重在闭环,这个环即PDCA循环,Plan(计划)-> Do(执行)-> Check(检查)-> Act(处理),对于我们的故障复盘来说,即所有的改进事项都必须经过故障演练,通过实战演练来确保改进计划一定是有效的。</p><h2>复盘过程中的几个关键问题</h2><p><img src="/img/bVc30lU" alt="image.png" title="image.png"></p><p>在复盘过程中,可能很多参与的同学由于经验或者背景不一样,大家对故障的理解不一定一致,那么复盘的owner要多问一些问题,来引发大家的讨论和思考,从以往的经验中,我们总结了几类问题,大家可以把这个作为讨论的框架:</p><p>1.故障的根因是什么,它是如何影响业务可用性的?<br>当前我们在聊的这个是根因吗?从业务场景对应的链路上看,这个系统(组件)是强依赖吗?依赖是否合理、有无兜底机制。这次的变更流程是否完善、三板斧落实的是否到位。对应的观测指标是否能反应系统的真实状态,应急策略是否有效等等。</p><p>2.故障为什么会发生,可以避免或者降低发生概率吗?<br>也就是所谓的提升MTBF,如果是变更引起的,那么要考虑变更流程是否完善,是否按照流程规范操作,有无对应的防御机制。如果是某个系统组件失效导致的,那么要评估该组件的可用性是多少,与它所在的链路是否匹配,这条链路是否要设计兜底方案等。如果是外部原因引起的,那么我们对外部的这个依赖是否有过认真的评估,对方的可用性能够满足我们的诉求。</p><p>3.我们应该做什么,才能更快地恢复业务?</p><ul><li>监控告警 - 这个故障是如何被发现的,监控告警是否完善,我们能否更快地发现这个问题。</li><li>管理制度 - 人员值班响应oncall是否及时,关键人员是否就位,关键岗位有无backup机制,系统owner对负责的组件是否足够熟悉。</li><li>定位效率 - 现有的排查工具是否好用,有无需要优化的地方,故障定位的时间能否再缩短一点,故障的处理原则是以止血恢复优先,当时的故障处理过程中,有无跑偏方向。</li><li>应急预案 - 故障处理过程中,是否有应急预案,应急预案是否奏效?日常是否通过故障演练来验证应急预案的有效性。</li><li>架构设计 - 架构本身的高可用是否完善,是否具有容灾能力。</li><li>流程规范 - 现有的制度规范是否完善,有无需要优化的。</li></ul><h2>故障定责</h2><p>故障定责每个公司都有对应的定责制度,这里不展开太多,只写几个关键的观点。</p><h3>定责对应的首先是团队,其次是个人</h3><p>很多故障只是表象,大部分根因深挖下去,都会有技术管理的因素,虽然引发故障的操作可能是个人,但是更应该从团队的视角去看问题,避免把根因只归结到某个人身上。</p><h3>鼓励快速止血和积极参与</h3><p>对于故障处理过程中,积极参与定位、止血等操作的,给予正面的肯定。</p><h3>第三方默认无责</h3><p>即谁引入了第三方的技术组件,谁就要对其可用性负责。即我们在使用外部技术组件的时候,要仔细评估对方的可用性情况,以及我们的兜底方案等等。</p><h3>红线和军规</h3><p>红线和军规是我们的底线,坚决不能违反。现在的很多条款,都是以往的各个故障中沉淀出来,我们必须遵守且尊重这些红线军规,把它当做我们研发人员的铁律。</p><h3>对于重复的错误必须严肃</h3><p>对待未知的故障不可怕,可以用来丰富我们的稳定性建设经验,但是重复的踩坑就需要认真对待了,要思考为什么以往的改进事项没有落实到位、是否人员意识需要加强、对稳定性的重视度不够等等。</p><p>复盘不是故障的结束,改进事项经过验收才算彻底结束,因此每一个改进事项的相关方,都应积极主动push完成。同时,我们要最大化利用好复盘文档的价值,如可以考虑新人入职后,组织学习以往的复盘文档,吸收前人经验,避免重复踩坑。</p><p>很多问题背后都是一系列的原因,在复盘的过程中,除了唯一根因,还要把关联的原因和问题一起来看,避免头痛医头脚痛医脚的情况,争取能够体系化的解决问题。</p><p>(本文作者:孟闯)</p><p><img src="https://segmentfault.com/img/bVc0Nn0" alt="图片" title="图片"></p><blockquote>本文系哈啰技术团队出品,未经许可,不得进行商业性转载或者使用。非商业目的转载或使用本文内容,敬请注明“内容转载自哈啰技术团队”。</blockquote>
从一个生产的问题分析ElasticSearch负载均衡算法
https://segmentfault.com/a/1190000042823120
2022-11-15T17:52:43+08:00
2022-11-15T17:52:43+08:00
哈啰技术
https://segmentfault.com/u/hellotech
0
<h2>背景</h2><p>负载均衡是分布式系统里最常用的能力,实现方式有很多,轮询、随机、加权轮询、一致性hash等文章很多,今天要讲的是遇到的一个真实的生产问题。</p><p>公司内部的ES访问架构一般是,Java应用--->SLB(域名)---->ES ingest node (no data) --> ES data node,其中ingest节点是对外暴露的,供Java应用访问,承担了一个纯client角色,不提供数据存储和倒排索引检索服务。这其中SLB是为了方便起到一个域名和负载均衡的功能,绑定后端的n个client节点,并且做到对业务透明,但是毕竟还是有开销的,多了一次网络rpc的转发(尽管很快),同时也是多花了一份钱。所以在930的时候我们把SLB去掉了,并且进行了验证完全没有问题,这其中还要得益于es本身就支持ip配置列表,并且自身实现了负载均衡的功能。更改之后的访问链路,Java应用--->ES ingest node -->ES data node。</p><p>就在缩容的时候,我们遇到了问题,我们更改了es client里面配置的ip列表,结果出现了超时,同时观察到一个现象,每次更新ip列表的时候,总有一台机器的连接数明显高于其他机器,这是为何呢?关键节点如下:</p><ul><li>step1 下午16:xx 更新了es client里的机器列表,系统表现正常</li><li>step2 晚上20:5x 开始下线数据节点,系统出现了少量超时和报错,并且观察到有一台es的client节点流量明显高于其他机器,出现了负载不均衡</li><li>step3 晚上21:1x 以为es 流量高的节点有问题,所以进行了下线</li><li>step4 晚上21:2x 随着那台client节点下线,另外一台新的client节点又出现了流量过高的情况,并且超时一波一波,像是定时发生的</li><li>step5 怀疑是不是es client sdk 初始化有问题,代码里创建了新的es client的时候,老的未正常销毁,于是开始分批重启Java应用,让他重新初始化es client,而不是做热替换,分6批,每批大概10台机器</li><li>step6 观察到超时依然持续,负载不均衡的问题,依然没有解决,同时超时从一波波变成了持续但是少量,相当于原来超时的波峰被均匀打散到各个时间段了</li></ul><p><img src="/img/remote/1460000042823122" alt="图片" title="图片"></p><ul><li>step7 随后发现,es client里的ip列表配错了,里面配置了data node 数据节点,而正好20:5x下线了这几台机器,这几台已经不可用了</li><li>step8 修正es client连接的ip列表,系统报错消失,负载又均衡了,系统恢复正常</li></ul><h2>问题</h2><p>从负载不均衡的表现上来看,应该是配置的ip列表了,有机器不可用了,那么这台机器的下一台可用的机器,流量就会比别的机器明显高,出现了负载不均衡的问题,应该是es的负载均衡的逻辑导致的,因此决定翻一翻es的负载均衡的算法,详细看看。</p><p>先抽象一下问题,在RR的负载均衡算法下,有 ServerA、 ServerB、ServerC、ServerD和ServerE 5台机器,当ServerD不可用的时候,ServerE的流量会明显增高,当ServerE不可用的时候,ServerA的流量会明显增高。</p><p>1.为什么轮询的负载均衡算法里,坏节点的下一台机器流量会明显高?<br>2.为什么会超时?为什么超时最开始一波波的,重启后超时会打散了?<br>3.ES是如何处理es client里的坏节点的?如果是加黑名单,为什么还会出现负载不均衡和超时问题?</p><h2>源码分析</h2><h3>整体流程</h3><p>抽茧剥丝,去掉所有干扰因素,今天就主要看看es的负载均衡的实现。</p><pre><code>/**
* Sends a request to the Elasticsearch cluster that the client points to.
* Blocks until the request is completed and returns its response or fails
* by throwing an exception. Selects a host out of the provided ones in a
* round-robin fashion. Failing hosts are marked dead and retried after a
* certain amount of time (minimum 1 minute, maximum 30 minutes), depending
* on how many times they previously failed (the more failures, the later
* they will be retried). In case of failures all of the alive nodes (or
* dead nodes that deserve a retry) are retried until one responds or none
* of them does, in which case an {@link IOException} will be thrown.
*
* This method works by performing an asynchronous call and waiting
* for the result. If the asynchronous call throws an exception we wrap
* it and rethrow it so that the stack trace attached to the exception
* contains the call site. While we attempt to preserve the original
* exception this isn't always possible and likely haven't covered all of
* the cases. You can get the original exception from
* {@link Exception#getCause()}.
*
* @param request the request to perform
* @return the response returned by Elasticsearch
* @throws IOException in case of a problem or the connection was aborted
* @throws ClientProtocolException in case of an http protocol error
* @throws ResponseException in case Elasticsearch responded with a status code that indicated an error
*/</code></pre><pre><code> public Response performRequest(Request request) throws IOException {
InternalRequest internalRequest = new InternalRequest(request);
return performRequest(nextNodes(), internalRequest, null);
}</code></pre><p>这里有个nextNodes() ,返回值是一个NodeTuple<iterator>是一个服务器列表,暂且不去看他怎么调整的,看看他怎么用的(分析过程需要理解es是怎么使用Apache的httpclient去请求服务器的,这里直接公布答案,host信息会带在request里面构造成一个类似 HttpGet("https://host:port/search?q=0")这样的一个对象传给httpclient执行),从后面使用的地方来看,try里面选中的是nodeTuple.nodes.next(), 由于这是第一次从list里取数据,因此是头结点。</p><p>取了数组的第一个做为第一次请求:RequestContext context = request.createContextForNextAttempt(nodeTuple.nodes.next(), nodeTuple.authCache);</p><p>注释说的很清楚,使用了RR负载均衡算法,并且错误的节点会被静默处理(加入黑名单,1分钟,最大30分钟 )。</p><blockquote>Selects a host out of the provided ones in a round-robin fashion. Failing hosts are marked dead and retried after a certain amount of time (minimum 1 minute, maximum 30 minutes).</blockquote><p>来到了这个关键代码,nextNodes,是干什么的?这其实是对原来的列表进行了排序并且剔除了dead node。</p><pre><code>static Iterable<Node> selectNodes(NodeTuple<List<Node>> nodeTuple, Map<HttpHost, DeadHostState> blacklist,
AtomicInteger lastNodeIndex, NodeSelector nodeSelector) throws IOException {
/\*
\* Sort the nodes into living and dead lists.
\*/
List<Node> livingNodes = new ArrayList<>(Math.max(0, nodeTuple.nodes.size() - blacklist.size()));
List<DeadNode> deadNodes = new ArrayList<>(blacklist.size());
for (Node node : nodeTuple.nodes) {
DeadHostState deadness = blacklist.get(node.getHost());
if (deadness == null || deadness.shallBeRetried()) {
livingNodes.add(node);
} else {
deadNodes.add(new DeadNode(node, deadness));
}
}
if (false == livingNodes.isEmpty()) {
/\*
\* Normal state: there is at least one living node. If the
\* selector is ok with any over the living nodes then use them
\* for the request.
\*/
List<Node> selectedLivingNodes = new ArrayList<>(livingNodes);
nodeSelector.select(selectedLivingNodes);
if (false == selectedLivingNodes.isEmpty()) {
/\*
\* Rotate the list using a global counter as the distance so subsequent
\* requests will try the nodes in a different order.
\*/
Collections.rotate(selectedLivingNodes, lastNodeIndex.getAndIncrement());
return selectedLivingNodes;
}
}</code></pre><p>这里有几个关键信息,<br>1.要返回的列表是new 出来的,跟原来的你配置进去的不干扰<br>2.如果有死节点,这里就直接清理掉了,关键判断逻辑 deadness.shallBeRetried()稍后介绍<br>3.使用的是集合的Collections.rotate()实现了轮询机制,稍后介绍<br>4.轮询的之后返回的是一个调整完排序的新的列表给到performRequest调用next()获取了第一个节点<br>5.rotate的第二个参数rotate是多个线程同时共享使用的,每次+1, 因此实现了轮询的作用</p><h3>轮询算法</h3><p>过程如下,假如原来你配置的列表, A,B,C,D,假设正常情况没有坏节点的情况下:<br>第一次Collections.rotate("A,B,C,D", 0) = "A,B,C,D"<br>第二次lastNodeIndex=1,Collections.rotate("A,B,C,D", 1) = "D,A,B,C"<br>第三次lastNodeIndex=2,Collections.rotate("A,B,C,D", 2) = "C,D,A,B"<br>第四次lastNodeIndex=3,Collections.rotate("A,B,C,D", 3) = "B,C,D,A"<br>第五次lastNodeIndex=4,Collections.rotate("A,B,C,D", 4) = "A,B,C,D"</p><p>后面就是重复了,可以看到第一个参数始终不变,变动的是第二个参数,实现了一个环状的滚动。</p><p>其中这几次的调用,不一定是同一个线程,因此可能是并发进行的,第二个参数是一个AtomicInteger对象,保证了线程的安全性。</p><h3>注入选择逻辑</h3><p>需要提一下的是,这里面有个NodeSelector对象干扰,可以看到,在调用rotate之前,调用了这个对象的select方法,点进去看到的是一个接口,那么这里很大概率就是一个扩展点了,真实的我们在用的时候,有个默认值,NodeSelector ANY,他的方法体里是个空的,什么也没做,也就是默认,什么都不做,所以这个是没用的,不用去关心。</p><pre><code>/\*\*
\* Select the {@link Node}s to which to send requests. This is called with
\* a mutable {@link Iterable} of {@linkplain Node}s in the order that the
\* rest client would prefer to use them and implementers should remove
\* nodes from the that should not receive the request. Implementers may
\* iterate the nodes as many times as they need.
\* <p>
\* This may be called twice per request: first for "living" nodes that
\* have not been blacklisted by previous errors. If the selector removes
\* all nodes from the list or if there aren't any living nodes then the
\* {@link RestClient} will call this method with a list of "dead" nodes.
\* <p>
\* Implementers should not rely on the ordering of the nodes.
\*/
void select(Iterable<Node> nodes);</code></pre><h3>坏节点的处理</h3><p>如果出现了异常,就会走入到catch代码块里。</p><pre><code>try {
httpResponse = client.execute(context.requestProducer, context.asyncResponseConsumer, context.context, null).get();
} catch(Exception e) {
RequestLogger.logFailedRequest(logger, request.httpRequest, context.node, e);
onFailure(context.node);
Exception cause = extractAndWrapCause(e);
addSuppressedException(previousException, cause);
if (nodeTuple.nodes.hasNext()) {
return performRequest(nodeTuple, request, cause);
}
if (cause instanceof IOException) {
throw (IOException) cause;
}
if (cause instanceof RuntimeException) {
throw (RuntimeException) cause;
}
throw new IllegalStateException("unexpected exception type: must be either RuntimeException or IOException", cause);
}</code></pre><p>这里面有两个关键信息,<br>1.onFailure(context.node) 把当前的这个节点加入黑名单里<br>2.return performRequest(nodeTuple, request, cause); 递归调用下一个节点,直到有正常节点响应</p><p>那么加入黑名单之后会发生什么呢?从刚刚的select node 逻辑里可以看到,blackList节点里的节点需要通过shallBeRetried的判断,要不要加入到living列表里,用来这次请求,这个方法如下:</p><pre><code>/\*\*
\* Indicates whether it's time to retry to failed host or not.
\*
\* @return true if the host should be retried, false otherwise
\*/
boolean shallBeRetried() {
return timeSupplier.get() - deadUntilNanos > 0;
}</code></pre><p>timeSupplier.get()是当前时间<br>deadUntilNanos则是一个变量,决定了是否这个节点要被去静默处理(不请求)</p><p>第一次请求不通的时候,他等于当前时间加1分钟,也就是静默1分钟,再此后的1分钟内这个shallBeRetried返回的都是false,就是不需要重试,而这个1分钟会随着失败次数的增加越来越长。</p><pre><code>DeadHostState(Supplier timeSupplier) {
this.failedAttempts = 1;
this.deadUntilNanos = timeSupplier.get() + MIN_CONNECTION_TIMEOUT_NANOS;
this.timeSupplier = timeSupplier;
}</code></pre><p>后面再次请求不通的时候,他静默的时长是一个算法,逐步加长的算法,例如,1分钟,3分钟,15分钟,30分钟最大30分钟,数值为举例,可以自行计算。</p><pre><code>/\*\*
\* Build the dead state of a host given its previous dead state. Useful when a host has been failing before, hence
\* it already failed for one or more consecutive times. The more failed attempts we register the longer we wait
\* to retry that same host again. Minimum is 1 minute (for a node the only failed once created
\* through {@link #DeadHostState(Supplier)}), maximum is 30 minutes (for a node that failed more than 10 consecutive times)
\*
\* @param previousDeadHostState the previous state of the host which allows us to increase the wait till the next retry attempt
\*/
DeadHostState(DeadHostState previousDeadHostState) {
long timeoutNanos = (long)Math.min(MIN\_CONNECTION\_TIMEOUT\_NANOS \* 2 \* Math.pow(2, previousDeadHostState.failedAttempts \* 0.5 - 1),
MAX\_CONNECTION\_TIMEOUT\_NANOS);
this.deadUntilNanos = previousDeadHostState.timeSupplier.get() + timeoutNanos;
this.failedAttempts = previousDeadHostState.failedAttempts + 1;
this.timeSupplier = previousDeadHostState.timeSupplier;
}</code></pre><h2>答案</h2><p>源代码看完之后,尝试回答刚刚的问题。</p><p>1.为什么会负载不均衡?<br>当其中一个节点坏了之后,他会启用重试逻辑,重试他下一个节点,直到正常,因此坏节点上面的流量全部挪给了下一个健康节点,因此出现了负载不均衡。</p><p>ps: 假如坏掉的是最后一个节点,ABCD的D坏了,根据条件if (nodeTuple.nodes.hasNext()) {}他不会重试了,为什么第一个节点会出现负载不均衡呢?</p><p>最开始这里困扰了一下,后来再详细的分析发现,performRequest取的始终是排序变换完成之后的第一个节点,因此虽然你配置的是ABCD,D在最后一个,但是实际使用的时候,D一定是排第一个,这也就是刚刚为什么强调返回的对象是new的一个数组。</p><p>2.为什么会超时?为什么超时最开始一波波的,重启后超时会打散了?<br>因为变更ip 列表是所有java机器几乎同时变更的,这个列表里有几个坏机器,因此触发了静默的逻辑,也就是第一次全部失败报错,然后静默了1分钟之后,再次请求,再次集体报错,下一次请求的节奏始终一致,因此这个超时是一波一波的像定时任务一样。</p><p>随后因为分批重启,client请求坏节点的时间被打散了,因此后面静默结束的时间也不一样,因此超时被分散了。</p><p>3.为什么加黑名单还会报错?<br>虽然会加黑名单,但是第一次请求还是报错了(但没有日志)。另外静默是会结束的,结束之后再请求,还是会报错。因此不健康的节点,也是会接受请求的。</p><p>另外值得一提的是,虽然你设置的是1.5s超时,但是因为这个重试逻辑,实际上的超时时间是大于1.5s的,如果里面有2个坏节点,那么就会超过3s,因此全链路也会等待超时,这让你看起来像是超时时间未设置生效一样。</p><h2>结论</h2><p>使用es restclient直接访问es集群的时候,通过ip直连而不是slb来连接的时候,由于es的负载均衡算法问题,会出现以下现象。</p><p>1.一波一波的es访问超时,且没有日志, (debug日志生产一般不开),重试时间过久会导致全链路超时。<br>2.坏掉的节点的下一个节点上的流量会明显高于其他节点,负载不再均衡。<br>3.全链路会报错,因为你配置的超时时间是每次请求es的Socket时间,而由于他自己会重试好几次,因此真实的search时间会超过你设置的超时时间,导致上游的soa cancel报错。这也是为什么es的响应头里告诉你took花了500ms,实际上你的search方法却花了1s的原因。</p><p>所以配置ip列表的时候,检查仔细了,不要配置无效节点,否则会因为重试逻辑导致你预料不到的情形。</p><p>(本文作者:任天兵)</p><p><img src="https://segmentfault.com/img/bVc0Nn0" alt="图片" title="图片"></p><blockquote>本文系哈啰技术团队出品,未经许可,不得进行商业性转载或者使用。非商业目的转载或使用本文内容,敬请注明“内容转载自哈啰技术团队”。</blockquote>
虚拟列表在哈啰商城H5中的实践
https://segmentfault.com/a/1190000042775780
2022-11-09T14:10:17+08:00
2022-11-09T14:10:17+08:00
哈啰技术
https://segmentfault.com/u/hellotech
2
<h2>为什么要用虚拟列表</h2><p>哈啰好物商城中,存在大量的长列表数据,例如下图列出的商品瀑布流、特卖会场列表等。用户滑动到页面底部,则加载新的数据进来,页面上的DOM节点越来越多,容易导致页面卡顿,交互不流畅。针对这种长列表的场景,我们可以采用虚拟列表来做优化。</p><p><img src="/img/remote/1460000042775782" alt="图片" title="图片"><br> <br><img src="/img/remote/1460000042775783" alt="图片" title="图片"></p><h2>什么是虚拟列表</h2><p>虚拟列表,顾名思义,并不是真实数据列表的一个体现,而是只截取一部分列表数据用于填充可视区域。</p><p><img src="/img/remote/1460000042775784" alt="图片" title="图片"></p><p>以品牌会场页面为例,在屏幕可展示范围内可能只有3条卡片数据,而在屏幕可视范围之外,我们可能已经滑动请求了几百上千条数据,这些数据不被我们看见,却在页面DOM中真实存在。我们完全可以只渲染屏幕中间可以被看见的几张卡片,当用户滚动时,根据滚动距离来计算替换这几张卡片里的数据,来达到模拟真实列表滚动的效果。</p><h2>如何实现一个虚拟列表</h2><p>我们先写一个简单的Demo,来模拟页面下拉加载数据的场景, 每页加载20条数据,下拉到底部继续加载20条数据,我们可以在开发者工具中看到,DOM节点在持续的增加。</p><p><img src="/img/remote/1460000042775785" alt="图片" title="图片"></p><p>现在我们来一步步实现一个虚拟列表。假设我们的滚动可视区域高度为1000px,每个列表项高度为100px, 那么可视区域可以渲染出10条数据。当滚动距离为0时,渲染的列表项数据索引是从0到9( 由于我们用数组的slice方法切割数组时,索引是一个左闭右开区间,因此这里做数据切割时是slice(0, 10))。当滚动了100px之后,相当于把第一个列表项滚动上去了,那实际渲染的列表项数据就可以替换成从1到10(slice(0, 11))。</p><p>同理可推,当滚动了scrollTop距离之后,相当于把前面(scrollTop / itemHeight)个列表项滚动上去了,那实际渲染的列表项数据就是从(scrollTop / itemHeight) 到 (scrollTop / itemHeight) + 9。</p><p>当然,如果滚动的scrollTop不足一个列表项高度,则当前列表项还在可视区域内,不能替换,所以我们使用scrollTop/itemHeight时,要向下取整。</p><p><img src="/img/remote/1460000042775786" alt="图片" title="图片"></p><p>定义以下变量:</p><ul><li>滚动区域高度记为scrollContainerHeight</li><li>每一个列表项的高度是固定的,记为itemHeight</li><li>列表可视范围内的列表项数量,记为visibleCount</li><li>滚动的距离记为scrollTop</li><li>完整的列表数据,记为listData</li><li>实际渲染的列表数据,记为visibleList</li><li>列表项起始索引,记为startIndex</li><li>列表项结束索引,记为endIndex</li><li>列表向下偏移量,记为scrollOffset</li></ul><p>得出以下计算公式:</p><ul><li>visibleCount = Math.ceil(scrollContainerHeight / itemHeight)</li><li>startIndex = Math.floor((scrollTop / itemHeight))</li><li>endIndex = startIndex + visibleCount</li><li>visibleList = listData.slice(startIndex, endIndex)</li><li>scrollOffset = startIndex * itemHeight</li></ul><p>我们用代码实现看看:</p><pre><code>
<template>
<div @scroll="scrollEvent($event)" class="list-container">
<div :style="{height: `${totalHeight}px`, transform: `translateY(${scrollOffset}px)`}" class="list-wrapper">
<div v-for="item in visibleList" :key="item" class="list-item">
{{ item }}
</div>
</div>
</div>
</template></code></pre><pre><code>
const totalHeight = computed(() => {
return listData.value.length * itemHeight.value;
})
const scrollEvent = (e) => {
const scrollTop = e.target.scrollTop; // 获取滚动距离
startIndex.value = Math.floor(scrollTop / itemHeight.value); // 起始索引为滚动距离/单个列表项高度
endIndex.value = startIndex.value + visibleCount.value; // 结束索引为起始索引+可视区域内的列表项数量
visibleList.value = listData.value.slice(startIndex.value, endIndex.value);
scrollOffset.value = startIndex.value * itemHeight.value; // 偏移量为已滑动出去的列表项数量*单个列表项高度
}</code></pre><p>看下现在的实现效果,无论列表有多少项,页面中始终只会渲染10个列表DOM。<br><img src="/img/bVc3D5O" alt="" title=""></p><h2>不定高的虚拟列表</h2><p>上面的Demo只是讲述了列表项高度固定时的一个最基础的演示,实际业务应用中,我们还经常会遇到列表项高度不固定的场景,例如商品瀑布流。由于列表项高度不同,如果仍然使用上面的方式去计算索引替换数据会不准确,页面会发生抖动。我们可以先设置一个预估的高度,当列表项加载出来后,获取实际渲染的列表项高度,进行更新。</p><p>预估列表项每一项的高度为50px,那么我们得到初始的position数组为, 其中index为列表项数据的实际索引,height为该列表卡片的高度,top为该卡片距离顶部的距离,bottom为该卡片底边的位置。</p><p><img src="/img/remote/1460000042775787" alt="图片" title="图片"></p><p>当页面渲染出来后,我们获取到每一项的实际高度,如果实际高度和之前预估的高度不一致,就更新该项的height值。</p><p>例如:index为0的列表项实际高度为44px,则height更新为44px,由于高度小了6px,底边的位置也就向上了6px,所以bottom更新为50 - 6 = 44。</p><p>假设index为1的列表项实际高度就是我们预估的50,但由于index为0的列表项高度减少,导致index为1的列表项的top和bottom也需要相应的减少6, 由此我们发现,当某一项高度改变后,在这一项之后的所有列表项的top和bottom都会受到影响,我们都要去做一次数据更新。</p><pre><code>const updateItemHeight = () => {
const nodes = visibleItemRef.value;
nodes.forEach((node) => {
if (!node) {
return;
}
const rect = node.getBoundingClientRect();
const id = node.id;
const oldHeight = positions.value[id].height; // 获取当前渲染的列表项前一次高度
const currentHeight = rect.height; // 获取当前渲染的列表项当前高度
const diffHeight = oldHeight - currentHeight; // 获取两次高度的差值
if (diffHeight !== 0) {
positions.value[id].height = currentHeight; // 更新这一项的高度
positions.value[id].bottom = positions.value[id].bottom - diffHeight;
}
});
const startId = +nodes[0].id; // 当前渲染的列表项第一项的实际索引
// 由于当前索引的高度有变化,从当前索引往后的所有项的top和bottom都要更新
for(let i = startId+1; i<positions.value.length; i+=1) {
positions.value[i].top = positions.value[i-1].bottom; // 当前项距离顶部的距离就等于上一项底边的位置
positions.value[i].bottom = positions.value[i].top + positions.value[i].height// 当前项底边的位置就等于当前项距离顶部的位置+当前项的卡片高度
}
};</code></pre><p><img src="/img/remote/1460000042775788" alt="图片" title="图片"></p><p>以上图渲染的列表项为例,当滚动距离超过22时,说明index=0的这一张卡片已经被滑出可视区域,此时的startIndex可以替换为1。</p><p>在固定高度的情况下,我们的startIndex=滚动距离/单张卡片的高度,即Math.floor(scrollTop / itemHeight.value)。</p><p>在不定高度的情况下,我们只需在position数组中,查找到第一个bottom > scrollTop的卡片,记为startIndex, 偏移量修改为startIndex - 1的卡片的bottom值。</p><pre><code>const scrollEvent = (e) => {
const scrollTop = e.target.scrollTop; // 获取滚动距离
const startItem = positions.value.find((p) => {
return p.bottom > scrollTop;
});
if (startItem) {
startIndex.value = startItem.index; // 起始索引为第一个bottom值大于scrollTop的
} else {
startIndex.value = 0;
}
endIndex.value = startIndex.value + visibleCount.value; // 结束索引为起始索引+可视区域内的列表项数量
visibleList.value = listData.value.slice(
startIndex.value,
endIndex.value
);
if (startIndex.value > 0) {
scrollOffset.value = positions.value[startIndex.value - 1].bottom; // 偏移量为已滑动出去的列表项的底边位置
} else {
scrollOffset.value = 0;
}
};</code></pre><p>上述所有代码实现只是从最基本的思路入手实现的简单Demo,在实际实现过程中,我们还可以对虚拟列表的代码做很多的优化。例如结合分页请求、设置缓冲区、计算偏移量的方法用二分查找等方式降低搜索次数、滚动节流。当然,业界已经有很多封装好的库可以直接拿来用,例如vue-virtual-scroller。</p><h2>商城H5中的实践效果</h2><p>在商城H5的品牌特卖会场,我们在引入了vue-virtual-scroller的基础上,添加了下拉分页请求,效果如下:</p><p><img src="/img/remote/1460000042775789" alt="图片" title="图片"></p><p>(本文作者:马新新)</p><p><img src="https://segmentfault.com/img/bVc0Nn0" alt="图片" title="图片"></p><blockquote>本文系哈啰技术团队出品,未经许可,不得进行商业性转载或者使用。非商业目的转载或使用本文内容,敬请注明“内容转载自哈啰技术团队”。</blockquote>
重磅!哈啰 Quark Design 正式开源,新一代跨技术栈前端组件库
https://segmentfault.com/a/1190000042717171
2022-10-31T14:55:22+08:00
2022-10-31T14:55:22+08:00
哈啰技术
https://segmentfault.com/u/hellotech
0
<p><img src="/img/remote/1460000042717173" alt="图片" title="图片"></p><h2>Quark Design是什么?</h2><p>Quark(夸克)Design 是由哈啰平台 UED 和增长&电商前端团队联合打造的一套面向移动端的跨框架 UI 组件库。与业界第三方组件库不一样,Quark Design 底层基于 Web Components 实现,它能做到一套代码,同时运行在各类前端框架中。Quark Design 历经一年多的开发时间,已在集团内部大量业务中得到验证,本着“共创、共建、共享”的开源精神,我们于即日起将 Quark 正式对外开源!</p><p>Github地址:<a href="https://link.segmentfault.com/?enc=VRZ1AwybC0Vb59oqaPBq7g%3D%3D.VXURIHwvpDvO%2FyRYe4%2FbMioSO7ayALsjbfOTTqQmOoRjTiN2qd2TPC7Wvc9IUxoG" rel="nofollow">https://github.com/hellof2e/q...</a> (求star、求关注~😁)</p><p><img src="/img/remote/1460000042717174" alt="图片" title="图片"></p><p>注:文档表现/样式参考了HeadlessUI/nutui/vant等。</p><h2>Quark Design与现有主流组件库区别是什么?</h2><p>Quark(夸克)有别于业界主流的移动端组件库,Quark 能同时运行在业界所有前端框架/无框架工程中,做到真正的技术栈无关!我们不一样,:)</p><ul><li>不依赖技术栈(eg. Vue、React、Angular等)</li><li>不依赖技术栈版本(eg. Vue2.x、Vue3.x)</li><li>全新的Api设计(eg. 弹窗的打开属性由传统的 Visible 调整为符合浏览器原生弹窗的 open等)</li><li>公司前端技术生态项目技术栈多时,保持视觉/交互统一</li><li>完全覆盖您所需要的各类通用组件</li><li>支持按需引用详尽的文档和示例</li><li>支持定制主题</li></ul><h3>性能优势-优先逻辑无阻塞</h3><p>我们以对 React 组件的 Web Components 化为例,一个普通的 React 组件在初次执行时需要一次性走完所有必须的节点逻辑,而这些逻辑的执行都同步占用在 js 的主线程上,那么当你的页面足够复杂时,一些非核心逻辑就将会阻塞后面的核心逻辑的执行。</p><p>比如首次加载时,你的页面中有一个复杂的交互组件,交互组件中又包含 N多逻辑和按钮等小组件,此时页面的首次加载不应该优先去执行这些细节逻辑,而首要任务应当是优先渲染出整体框架或核心要素,而后再次去完善那些不必要第一时间完成的细节功能。例如一些图像处理非常复杂,但你完全没必要在第一时间就去加载它们。</p><p>当我们使用 Web Components 来优化 React的时候,这个执行过程将会变得简洁的多,比如我们注册了一个复杂的逻辑组件,在 React 执行时只是执行了一个 createElement 语句,创建它只需要 1-2 微秒即可完成,而真正的逻辑并不在同时执行,而是等到“核心任务”执行完再去执行,甚至你可以允许它在合适的时机再去执行。</p><p>我们也可以简单的理解为,部分逻辑在之后进行执行然后被 render 到指定 id 的 Div 中的,那么为什么传统的组件为什么不能这么做呢?而非得 Web Components 呢?那就不得不提到它所包含的另一个技术特性:Shadow DOM。</p><h3>组件隔离(Shadow Dom)</h3><p>Shadow DOM 为自定义的组件提供了包括 CSS、事件的有效隔离,不再担心不同的组件之间的样式、事件污染了。这相当于为自定义组件提供了一个天然有效的保护伞。</p><p>Shadow DOM 实际上是一个独立的子 DOM Tree,通过有限的接口和外部发生作用。我们都知道页面中的 DOM 节点数越多,运行时性能将会越差,这是因为 DOM 节点的相互作用会时常在触发重绘(Repaint)和重排(reflow)时会关联计算大量 Frame 关系。</p><p><img src="/img/remote/1460000042717175" alt="图片" title="图片"></p><p>而对 CSS 的隔离也将加快选择器的匹配速度,即便可能是微秒级的提升,但是在极端的性能情况下,依然是有效的手段。</p><h2>Quark 能为你带来什么?</h2><p>提效降本几乎是所有企业的主旋律,Quark 本身除了提供了通用组件之外,我们还为大家提供了开箱即用的 CLI,可以让大家在直接在日常开发中开发横跨多个技术栈/框架的业务组件。比如一个相同样式的营销弹窗,可以做到:</p><ul><li>同时运行在不同技术栈(Angular、Vue、React等)的前端工程中</li><li>同时运行在不同版本的技术栈中,比如能同时运行在 Vue2.x、Vue3.x 中</li></ul><p>CLI 内部Beta版本已完成,开源日期略晚于Quark Design。</p><p>可先关注组织地址:<a href="https://link.segmentfault.com/?enc=Td7cFB3f7tlLs4EyPiCSnQ%3D%3D.Zzlnt%2BeZW6%2BV2udWsntzX1QkOj%2BTHrbTmcpljIs7qrs%3D" rel="nofollow">https://github.com/hellof2e</a>,关注更新。</p><p>适合场景:公司/团队内部发布一个独立的npm包,让其他团队安装使用,从而达到提效降本的目的。</p><pre><code>npm i -g @quarkd/quark-cli
npx create-quark</code></pre><p><img src="/img/remote/1460000042717176" alt="图片" title="图片"></p><h2>写在最后</h2><p>Quark Design 历经一年多的开发时间,期间有不少集团内部的同学参与并贡献了代码,在此先表示感谢,感谢大家对于 Quark Design 的热情和支持。同时在开源后也欢迎更多的社区同学参与共建 Quark Design,Quark Design loves u ❤️!如果对 Quark(夸克) 感兴趣的同学,欢迎扫码加入开发群。如果无法加入交流群(微信群扫码最多200人),可以添加微信:iderxu,拉你进群。</p><p><img src="/img/bVc3oQK" alt="image.png" title="image.png"></p><p>相关链接</p><ul><li>GitHub: <a href="https://link.segmentfault.com/?enc=Eol8LJ8fVkLyyjnLf5k%2F7Q%3D%3D.zTsgF4O8VRZawPZ5K8zjFbBGnK0lWsVHUVryc1xs51CZkt6Hv8322K17rFOkmQCy" rel="nofollow">https://github.com/hellof2e/q...</a></li><li>Doc: <a href="https://link.segmentfault.com/?enc=UCCJTE3ZYl%2FiYOdit509wA%3D%3D.tC3z79FGy36FMo45%2F3V7fVjVIcfGXvnld%2F%2B7rFpu8bvLeFdsu3jOsEvhOxp4iD3X" rel="nofollow">https://quark-design.hellobik...</a></li><li><a href="https://link.segmentfault.com/?enc=gDPgjO5q%2BuuxFR5KepbIdg%3D%3D.R1CNRrfmDzrnyGE0Bxuok7mPbc43uVVUA9BtymHxXVO5D1ooFRzsvcktIPRCV9X2cTkionYXBqIvdcW%2B1gUeEg%3D%3D" rel="nofollow">https://developer.mozilla.org...</a></li><li><a href="https://link.segmentfault.com/?enc=CmAT%2BC8pTgv7kExgfkucmA%3D%3D.BVnaGZ0el9kq18bpia6b9mKwuD1L%2BK11dXSOSplxjGk%3D" rel="nofollow">https://www.webcomponents.org/</a></li><li>2022 Web Components 趋势解读和发展: <a href="https://link.segmentfault.com/?enc=XHslF8hQe5VTKvhrHNXliA%3D%3D.RD8bMjrURtq02nXXucDlhDJzN2l3pouXsxs%2FzUwSHa7wBnqWbtpPmL4ltd2VmQgF" rel="nofollow">https://zhuanlan.zhihu.com/p/...</a></li></ul>
智能创意在哈啰的应用实践
https://segmentfault.com/a/1190000042653377
2022-10-20T10:15:38+08:00
2022-10-20T10:15:38+08:00
哈啰技术
https://segmentfault.com/u/hellotech
0
<h2>什么是创意</h2><h3>创意类型及组成</h3><p><img src="/img/remote/1460000042653379" alt="图片" title="图片"></p><p>创意的类型很多,包括商品广告创意、视频创意、UGC图文创意、营销活动创意等。右图是哈啰营销活动的banner和弹窗,可以看到banner和弹窗属于不同的创意样式,不同创意样式的元素和元素的属性也各不相同。我们在对创意进行优化的时候,可以发现样式乘以模板乘以元素数再乘以元素的属性数,这使得创意的组合是千变万化的。</p><h3>如何评价创意质量</h3><p><img src="/img/remote/1460000042653380" alt="图片" title="图片"></p><p>从算法的角度,图像质量评估有三种建模条件。一是全参考,我们同时有原始(无失真、参考)图像和失真图像,核心是对比两幅图像的信息量或特征相似度;二是半参考,只有原始图像的部分信息或从参考图像中提取的部分特征;三是无参考,也叫盲参考,只有失真图像,难度较高。有两种常用的评估指标,一是线性相关系数,也就是平时我们用的皮尔逊相关系数,用来评估两组数据之间的差异性。它的公式是两组数据的斜方差除以标准差的商值,其中N表示失真图像数。通过这个公式可以算出失真图像和真实图像的相关性,相关性越高,正值就越大,先决条件是它的数据必须要服从正态分布。如果不满足这个条件,就可以用下面的Spearman秩相关系数,在意的是在真实值和预测值序列中的排序位置。它跟皮尔逊相关系数实际上是一样的,都是越大越相关。</p><p><img src="/img/remote/1460000042653381" alt="图片" title="图片"></p><p>接下来介绍2016年提出的DeepBIQ模型,将原始图像切分成多个子区域,对多个子区域预测的分数进行平均来估计图像质量。这个模型之所以具有创新点,是因为首先它使用了不同的预训练模型,由于我们平时所拿到的图片数据量较少,就可以进行迁移学习,用训练好的模型固定它的网络权重,再使用现在较少的数据来进行网络的微调,把别人场景下的网络数据迁移到我们的场景当中。其次它使用了大量的图像块而不是整个图像进行的训练,同时使用了不同的特征和结果融合策略,可以看到中间的Fusion of Feature Vectors,通过输入图像的块状特征,经过了CNN的编码之后得到了特征的向量,再经过三种不同的融合策略,包括pooling+svr、comc+svr和svr+pooling,最后选取最好的一种进行模型的评估。</p><p><img src="/img/remote/1460000042653382" alt="图片" title="图片"></p><p>创意质量评估的第二部分是文案的通顺度。对于一个普通的句子序列,它的概率是多个概率的乘积。困惑度是导数的概念,它是句子概率乘积的导数,再开N次方。因此语言模型预测出句子出现的概率越大,就表明它的困惑度越小,也就是一个比较好的通顺度比较高的句子。</p><h3>智能创意搭建内容</h3><p>智能创意系统搭建主要分为四个部分,一是内容理解,如实体识别、分类、标签抽取、embedding和OCR。二是创意生成,包括程序化拼接、素材生成、布局生成和元素渲染。三是质量评估,就是上文提到的文本和图像质量评估。四是创意优选,包括bandit、CTR预估、组合搜索和多模态特征。</p><h2>如何进行创意生成</h2><h3>什么是生成模型</h3><p><img src="/img/remote/1460000042653383" alt="图片" title="图片"></p><p>生成模型是从一个分布为p_data的数据集中取样构成训练集去训练模型,模型会学习和模拟这一分布,我们就可以从学习到的分布中生成一些样本,样本尽可能让它与真实数据分布一致,如图像、文本等。</p><h3>为什么要研究生成模型</h3><p><img src="/img/remote/1460000042653384" alt="图片" title="图片"></p><p>一是生成模型代表我们具有能够表示和操控高维概率分布的能力,二是生成模型可以用有损失的数据进行训练,进行半监督学习,降低了我们获得数据样本的难度。三是有一些任务需要产生看起来真实的样本,如输入低分辨率的图片,生成模型可以产生接近于原分辨率的图片;从街道轮廓图生成真实图,从卫星图生成地图。这些图像复原和修复任务需要一些看起来真实的样本,生成模型可以去完成。</p><h3>生成对抗网络</h3><p><img src="/img/remote/1460000042653385" alt="图片" title="图片"></p><p>生成模型是去求解真实的概率分布,如果我们不在意概率分布本身的样子,只希望通过模型去生成与真实分布差不多的样本,我们就可以用生成对抗网络去建模。对抗是指我们需要构建两个网络,分别是判别网络和生成网络。判别网络的损失是交叉熵损失,生成网络学习的损失函数是判别网络的相反值。这是因为判别网络是为了去区分出真实样本和生成样本之间的差异,并让他们之间的区分度最大;生成网络用来生成样本,希望生成样本和真实样本区别越小越好,所以从建模的目的上说,这两个网络的损失函数需要是相反的值,加在一起是一个经典的零和博弈的问题。</p><p>这两个网络学习总目标是在判别网络损失函数最小的情况下,生成网络的损失函数也最小。训练过程就是在以下的两个步骤中交替进行,分别去训练这两个网络,对判别网络进行梯度上升,对生成网络进行梯度下降。在实际的训练过程中,并不是1+1交替进行,而是先去训练判别网络,因为只有好的判别网络之后,才能够更好地更新生成网络的参数。</p><h3>Transformer</h3><p><img src="/img/remote/1460000042653386" alt="图片" title="图片"></p><p>生成对抗网络主要运用的领域是图像生成,图像属于连续系统,难以对概率分布建模,但文本属于离散系统,用神经网络和softmax就可对概率分布建模,transformer模型主要用于文本的生成。</p><p>右图的transformer分为两个部分,分别是编码器部分和解码器部分。这里用一个比较形象的例子去阐述transformer的工作方式。第一步我们需要输入一条训练数据,以摘要生成为例,我们输入的训练数据是文章,需要输出的是摘要。在我们输入文章之后,经过encoder层得到一个编码,再经过decoder得到一个预测结果,预测结果代表词表中的词作为生成词的概率向量。比如词表中有三个词,作为生成词的概率向量分别是0.5、0.5、0.8,那么第三个词作为生成词的概率就比较高。第二步我们输入一个句子“张三回家了”中的“张”字,此时我们希望模型吐出“三”字的one-hot编码。第三步是通过刚才的机制去训练,减小损失,最终得到我们的生成模型。</p><h2>如何进行创意优选</h2><p><img src="/img/remote/1460000042653387" alt="图片" title="图片"></p><p>创意优选主要解决两个问题,一是创意到人的精准匹配,和一般的商品排序对比,创意多了很多多模态的内容,多模态内容的联合表征是创意优选的一个难点。二是长尾性加多样性,就是用户对于创意的疲劳度相对较高,它的解法是bandit模型,每种创意维护一个beta(win, lose)分布,win指的是创意被展现且被点击,lose指的是创意被展现但没有被点击,这个分布随着用户反馈的产生实时调整。</p><h2>哈啰智能创意系统展示</h2><p><img src="/img/remote/1460000042653388" alt="图片" title="图片"></p><p>哈啰在智能创意系统上做了很多实践,虽然还没有用到多模态的信息,但整个框架仍然是CTR+EE框架。同时我们也进行了一些内容理解的工作,如文案多分类、多标签提取等,对于新的创意和老的创意的解法也不一样。之后我们会上线图文搭配功能,会考虑到素材的美学搭配。同时在创意优选上会进行细粒度的优选,进行元素级的优选模型搭建。此外,会搭建更完善的报表和更智能的文案助手。</p><p>(本文作者:潘云凤)</p><p><img src="https://segmentfault.com/img/bVc0Nn0" alt="图片" title="图片"></p><blockquote>本文系哈啰技术团队出品,未经许可,不得进行商业性转载或者使用。非商业目的转载或使用本文内容,敬请注明“内容转载自哈啰技术团队”。</blockquote>