从 Serverfull 到 Serverless ,发生了什么

Serverfull 到 Serverless 的演变

%title插图%num

上图是 MVC 架构的 Web 应用部署之后的典型情况。上图中的整个蓝色部分就是服务端的边界,它是负责应用或代码的线上运维。而 Serverless 要解决的问题的边界就是服务端的边界,也就是服务端运维。

那么下面我们先来看一下服务端运维的发展史,也就是从一开始到 Serverless 的发展史。假设有一个 Web 应用,这个 Web 应用的研发涉及到两个角色:研发工程师和运维工程师。

研发工程师只关心应用的业务逻辑。具体来说就是,整个 MVC 架构 Web 应用的开发都归他负责,也就是从服务端界面 View 层,到业务逻辑 Control 层,再到数据存储 Model 层,整个 Web 应用的版本管理和线上 bug 修复都归研发工程师。

运维工程师则只关心应用的服务端运维事务。他负责部署上线小程的 Web 应用,绑定域名以及日志监控。在用户访问量大的时候,他要给这个应用扩容;在用户访问量小的时候,他要给这个应用缩容;在服务器挂了的时候,他还要重启或者换一台服务器。

  • Serverfull 时代。*开始的时候,研发工程师不用关心任何部署相关的事情。研发工程师每次发布新的应用后,运维工程师都负责部署上线*新的代码。运维工程师需要管理好迭代版本的发布,分支合并,将应用上线,遇到问题回滚。如果线上出了故障,还需要抓取日志发给研发工程师。

    Serverfull 时代将研发和运维完全隔离开来了。这种完全隔离开来的好处很明显:研发工程可以专心做好自己的业务,但是运维工程师就成了工具人了,就困在大量的运维工作中,处理大量琐碎的杂事。

  • DevOps 时代。运维工程师发现有很多事情都是重复性的工作,线上出故障了还得自己抓日志发给研发工程师,效率很低。因此运维工程师就开发了一套运维控制台,将部署上线和日志抓取的工作让研发工程师处理。

    这样,运维工程师可以稍微轻松点了,但是优化架构和扩缩容资源方案还是得负责。而研发工程师除了开发的任务,还要自己通过运维控制台发布新版本和解决线上故障。这个时候是研发兼运维 DevOps,研发工程师兼任了部分运维工程师的工作,但是这部分的工作就应该是研发工程负责的(比如版本控制、线上故障等),而且运维工程师将这部分工作工具化了,更加高效了,有 less 的趋势了。

  • 工业时代。运维工程师又基于研发工程师的开发流程,将运维控制台进一步提升,可以实现代码自动发布:代码扫描-测试-灰度验证-上线。这样一来,研发工程师只需要将*新的代码合并到 Git 仓库指定的 develop 分支,剩下的就由代码自动发布的流水线来负责了。这个时候研发工程师也不需要运维了,免运维 NoOps,研发工程师也就回到了当初,只需要关心自己的应用业务就可以了。

    同时,运维工程师发现资源优化和扩缩容方案也可以利用性能监控+流量估算解决。这样运维工程师的运维工作也全都自动化了。那么对于研发工程师来说,运维工程师的存在感越来越弱,需要运维工程师干的事情越来越少,都由自动化工具替代了。这就是 Serverless。

  • 未来。实现了免运维之后,运维工程师要转型去做更底层的服务,做基础架构的建设,提供更加智能、更加节省资源、更加周到的服务。而研发工程师可以完全不被运维的事情困扰,专注做好自己的业务,提升用户体验,思考业务价值。

    免运维 NoOps 并不是说服务端运维就不存在了,而是通过全知全能的服务,覆盖研发部署需要的所有需求,让研发工程师对它的感知越来越少。另外,NoOps 是理想状态,因为我们只能无限逼近 NoOps,所以说是 less,而不是 ServerZero。

Serverless 的 Server 限定了 Serverless 解决问题的边界,即服务端运维;less 说明了 Serverless 解决问题的目的,即免运维 NoOps。所以,Serverless 应该叫做服务端免运维,这也就是 Serverless 要解决的问题。

%title插图%num

什么是 Serverless

Serverless 要解决的就是将运维工程师的工作彻底透明化;而研发工程师只关心业务逻辑,不用关心部署运维和上线的各种问题。而要实现这种状态,那么就意味要对整个互联网服务端的运维工作进行*端抽象。而越抽象的东西,由于蕴含的信息量越大,所以越难定义。

但是,总的来说 Serverless 的含义有这两种:

  • 狭义 Serverless(*常见)是指 Serverless computing 架构 = FaaS 架构 = Trigger(事件驱动)+FaaS(Function as a Service,函数即服务)+BaaS(Backend as a Service,后端即服务,持久化或第三方服务)=FaaS + BaaS。
  • 广义 Serverless 是指服务端免运维,也就是具有 Serverless 特性的云服务。

%title插图%num

  狭义的 Serverless

我们日常工作提到的 Serverless 都是指狭义的 Serverless。

而这主要是因为历史原因,2014 年 11 月份,亚马逊推出了真正意义上的*款 Serverless FaaS 服务:Lambda。从此,Serverless 的概念才进入大多数人的视野,因此 Serverless 曾经一度就等于 FaaS。FaaS,函数即服务,它还有个名称叫作 Serverless Computing,它可以让我们随时随地创建、使用、销毁一个函数。

通常函数的使用过程:需要先从代码加载到内存,也就是实例化,然后被其他函数调用时执行。FaaS 中也是一样的,函数也需要实例化,然后被触发器 Trigger 调用。这两个*大的区别就是在 Runtime,也就是函数的上下文。FaaS 的 Runtime 是预先设置好的,都是云服务商提供的,我们可以使用但是无法控制。并且 FaaS 的 Runtime 是临时的,当 FaaS 的函数调用完之后,云服务商就会销毁这个实力,回收资源,也就意味着这个临时的 Runtime 会和函数一起销毁。因此,FaaS 推荐无状态的函数,也就是一个函数只要参数固定,那么返回的结果也必须是固定的。

那么将一开始的 MVC 架构的 Web 应用变成 Serverless 的话,那应该是怎样的呢?View 层是客户端展示的内容,通常并不需要函数算力;Control 层,就是函数的典型使用场景。在 MVC 架构中,一个 HTTP 的数据请求往往对应着一个 Control 函数,因此这个 Control 函数完全可以被 FaaS 函数代替。在 HTTP 的数据请求量大的时候,FaaS 函数会自动扩容多实例同时运行;在 HTTP 的数据请求量小的时候,又会自动缩容;当没有 HTTP 请求的时候,还会缩容至 0 实例。如下图所示:

%title插图%num

Control 函数变成了无状态的,并且函数的实例在不停地扩容缩容,那么此时想要持久化一些数据怎么办?当然 Control 函数中还是可以以操作数据库的命令方式来实现。但是,这种方式并不合理,因为 Control 层的方式变了,假如 Model 层还是以之前的那种方式,那么这种架构肯定是要散架。此时,就需要 BaaS 了,也就是将 Model 层进行 BaaS 化,BaaS 就是专门配合 FaaS 用的。下面 Model 层以 MySQL 为例,Model 层*好将操作数据库的命令封装成 HTTP 的 OpenAPI,提供给 FaaS 调用,自己控制这个 API 的请求频率以及限流降低等。这个 Model 层本身则可以通过连接池、MySQL 集群等方式去优化。如下图所示:

%title插图%num

至此,基于 Serverless 架构,传统的 MVC 架构完完全全被转化为了 View + FaaS + BaaS 的组合了。Serverless 毋庸置疑是因为 FaaS 架构才流行起来的。我们常见的 Serverless 都是指 Serverless Computing 架构,也就是由 Trigger、FaaS、BaaS 架构组成的应用。

  广义的 Serverless

广义的 Serverless 其实就是指服务端免运维,也是未来的趋势。要想达到 NoOps,需要具备:

  • 无需用户关心服务端的事情(容错、容灾、安全验证、自动扩缩容、日志调试)
  • 按使用量(调用次数、时长等)付费,低费用和高性能并行,大多数场景下节省开支。
  • 快速迭代&试错能力(多版本控制、灰度、CI&CD 等等)。

%title插图%num

为什么需要 Serverless 呢

在 2009 年的时候,有两种相互竞争的云虚拟化方法:

  • Amazon EC2,EC2 实例看起来很像物理硬件,用户可以从内核向上控制整个软件栈。
  • Google App Engine,是另一个针对特定领域的应用平台,它是一种将 stateless 计算层和 stateful 的存储层分类开来的一种应用程序结构。

*终市场使用了 Amazon 这种针对云计算的 low-level 虚拟机方式,而这种  low-level 虚拟机成功的主要原因是,早起的云计算用户希望在云中可以重新创建一个与本地计算机上相同的计算环境,以简化将其负载迁移到云上的工作。很明显,这种实际需求比为云重新编写新的程序更重要,尤其是在当时云计算能否成功尚不明确的情况下。

然后这种方式的缺点是,开发人员必须自己管理虚拟机,所以要么成为系统管理员,要么与它们一起设置环境。这些促使那些只使用简单化应用的客户向云服务商提出新要求,希望能有更简单的方式来运行这些简单应用。例如,假设应用希望将图片从手机端应用发送到云上,这需要创建*小的图片并将其放在 web 上,完成这个任务可能只需要几十行 JavaScript 代码,这与设置适当的服务器环境来运行这段代码相比,这个代码的开发是很微不足道的。

在这些需求的驱使下,Amazon 在 2015 年推出了一个名为 AWS Lambda service 的新服务。用户只需要编写代码,服务器供应和任务管理问题都由服务提供商来负责。编写的代码被打包为 FaaS(Function as a service),代表了 Serverless 计算的核心,但是云平台还提供了专门的 Serverless 框架,以满足特定的程序需求,如 BaaS(Backend as a Service)。简单地说,无服务计算定义如下:Serverless Computing = FaaS  + BaaS。同时,服务被视为无服务的话,那么必须能够自动扩缩容,并且根据实际使用情况计费。

★Cloud functions (i.e., FaaS) provide general compute and are complemented by an ecosystem of specialized Backend as a Service (BaaS) offfferings such as object storage, databases, or messaging.

%title插图%numServerless VS Serverful

在 Serverless 中,用户只需要使用高级语言编写云函数,选择触发云函数运行的事件就可以了。例如,加载一个图像到云存储中,或者向数据库添加一个很小的图片时,用户只需要编写相应的代码,而剩下的全都由 Serverless 系统来处理,比如选择实例、扩缩容、部署、容错、监控、日志、安全补丁等等。下面,总结了 Serverless 和传统方式的差异,我们将传统方式称为 Serverful。

%title插图%num

Serverless 和 Serverful 计算*关键的三个不同之处在于:

  1. **将计算与存储解耦。**存储和计算资源是分开提供的,相当于这两种资源的分配和计价都是独立的,通常来说存储资源是由一个独立的云服务来提供的,并且计算是无状态的。
  2. **执行代码而不需要管理资源分配。**与传统云计算用户需要请求资源的方式不同,serverless 是用户提交一段代码,云会自动给这段代码分配资源并执行。
  3. **以实际使用的资源量付费,而不是根据分配的资源数。**serverless 计费是根据一系列与执行相关的因素来计算的,例如代码的执行时间,而不实根据云平台,例如分配的 VM 的大小和数量

假如使用汇编语言和高级语言来形容的话,Serverful 计算类似于使用低级汇编语言进行编程,而 Serverless 计算类似于使用高级语言(例如 python)进行编程。例如,c = a + b 的简单表达式的汇编程序员必须显示选择一个或者多个寄存器,将值加载到这些寄存器中,执行运算,然后存储结果。这跟 Serverful 云编程的几个步骤是类似的:首先提供资源或者标识可用的资源,然后用必要的代码和数据加载这些资源,执行计算,返回或者存储结果,*终管理资源释放。而 Serverless 则提供了类似于高级编程语言的便捷性,Serverless 和高级编程语言也很相似性。比如,高级语言的自动内存管理不用再管理内存资源,而  Serverless 计算使程序员也不用再管理服务器资源。

%title插图%numAttractiveness of Serverless Computing

  对云服务提供商来说

  • Serverless 可以促进业务的增长,因为它使得云计算更容易编程,进而有助于吸引新客户并帮助现有客户更多地使用云计算。例如,*近的调查发现,大约 24% 的 Serverless 计算用户是云计算的新用户,30% 现有的 serverful 用户也使用了 Serverless 计算。
  • 短的运行时间、较小的内存占用和无状态特性使得云提供商更容易找到那哪些未使用的资源来运行这些任务,从而改进了资源复用。
  • 可以利用不太流行的计算机(实例类型由云提供商决定),比如对 serverful 云客户吸引较小的旧服务器。

★后面的两点可以*大化现有的资源并提高收益。

  对用户来说

  • 从编程效率的提高中获益,对于新手来说不需要理解云基础设施的前提下部署函数,老用户可以节省出部署的时间并聚焦于应用本身的问题。
  • 节约成本,因为云服务提供商将底层服务器的利用率提高了,并且函数只有在事件发生时才会计费,而且是细粒度的计费(通常是 100 毫秒),那么也就意味着只需要支付他们实际使用的部分而不是为他们预留的部分。

%title插图%numFaaS 是怎么运行的

在 Serverless 出现之前,我们要部署这样一个”Hello World”应用得何等繁琐。

  1. 我们要购买虚拟机服务;
  2. 初始化虚拟机运行环境,安装我们需要的应用运行环境,尽量和本地开发环境保持一致;
  3. 紧接着为了让用户能够访问我们刚刚启动的应用,我们需要购买域名,用虚拟机 IP 注册域名,配置 Nginx,启动 Nginx;
  4. *后我们还需要上传应用代码;
  5. 启动应用;

%title插图%num

采用 Serverless 之后,只需要简单的 3 步。Serverless 相当于对服务端运维体系进行了*端的抽象(抽象意味着用户请求 HTTP 数据请求的全链路,并没有质的改变,只是将全链路的模型简化了)。

  1. 之前在服务端构建代码的运行环境—函数服务
  2. 之前需要负载均衡和反向代理— HTTP 函数触发器
  3. 上传代码和启动应用—函数代码

整个启动过程如下图所示:

  1. 用户*次访问 HTTP 函数触发器时,函数触发器会 Hold 住用户的 HTTP 请求,并产生一个HTTP Request 事件通知函数服务;
  2. 函数服务检查有没有闲置的函数实例,如果没有函数实例,则去函数代码仓库拉取你的代码,初始化并启动一个函数实例;之后再传入 HTTP Request 对象作为函数的参数,执行函数。
  3. 函数执行的结果 HTTP Response 返回函数触发器,函数触发器再将结果返回给等待的用户客户端。

%title插图%num

★FaaS 和 PaaS 平台对比,*大的区别在于资源利用率。这也是 FaaS *大的创新点,FaaS 的应用实例可以缩容到 0,而 PaaS 平台至少要维持一台服务或容器。这主要是因为 FaaS 可以做到*速启动函数实例,而 PaaS 创建实例通常需要几十秒,为了保证你的服务可用性,必须一直维持至少一台服务器运行你的应用实例。

%title插图%num

FaaS 的*速启动

FaaS 中的冷启动是指从调用函数开始到函数实例准备完成的整个过程。冷启动关注的是启动时间,启动时间越短,对资源的利用率就越高。现在的云服务商,基于不同的语言特性,冷启动平均耗时基本在 100~700 毫秒之间。

下面这张图是 FaaS 应用冷启动的过程。其中,蓝色部分是云服务商负责的,红色部分是用户负责的。云服务商会不停地优化自己负责的部分,毕竟启动速度越快对资源的利用率就越高,例如冷启动过程中耗时较长的是下载函数代码。所以一旦你更新代码,云服务商就会偷偷开始调度资源,下载你的代码构建函数实例的镜像。请求*次访问时,云服务商就可以利用构建好的缓存镜像,直接跳过冷启动的下载函数代码步骤,从镜像启动容器,这个也叫预热冷启动。除此之外,还有预留实例策略也可加速或绕过冷启动时间。

%title插图%num

★FaaS 服务从 0 开始,启动并执行完一个函数,只需要 100 毫秒。这也是为什么 FaaS 敢缩容到 0 的主要原因。通常我们打开一个网页有个关键指标,响应时间在 1 秒以内,都算优秀。这么一对比,100 毫秒的启动时间,对于网页的秒开率影响真的*小。

为什么应用托管平台 PaaS 做不到*速启动呢?因为应用托管平台 PaaS 为了适应用户的多样性,必须支持多语言兼容,还要提供传统后台服务,例如 MySQL、Redis。这也就意味着,PaaS 在初始化环境时,有大量依赖和多语言版本需要兼容,而且兼容多种用户的应用代码往往也会增加应用构建过程的时间。

而 FaaS 设计之初就牺牲了用户的可控性和应用场景,来简化代码模型,并且分层结构进一步提升了资源的利用率。

%title插图%num

FaaS 的分层

FaaS 实例执行时,就如下图所示,至少是 3 层结构:容器、运行时 runtime、具体的函数代码。

  • 目前的 FaaS 实现方案中,容器方案可能是 Docker 容器、VM 虚拟机,甚至 Sandbox 沙盒环境。
  • 运行时 Runtime,就是你的函数执行时的上下文 context。Runtime 的信息包括代码运行的语言和版本,例如 Node.js v10,Python3.6;可调用对象,例如 aliyun SDK;系统信息,例如环境变量等等。

%title插图%num

这样分层的好处就是,容器层适用性更广,云服务商可以预热大量的容器实例,将物理服务器的计算碎片化。Runtime 的实例适用性较低,可以少量预热。容器和 Runtime 固定后,下载你的代码就可以执行了。通过分层,我们就可以做到资源统筹优化,让你的代码快速低成本地被执行。

另外,一旦容器 & Runtime 启动后,就会维持一段时间,这段时间内的这个函数实例就可以直接处理用户数据请求。当一段时间内没有用户请求事件发生(各个云服务商维持实例的时间和策略不同),则会销毁这个函数实例。

%title插图%num

%title插图%numFaaS 进程模型

从运行函数实例的进程角度来看,有两种模型:

  • 用完即毁型:函数实例准备好后,执行完函数就直接结束。FaaS *纯正的用法。
  • 常驻进程型:函数实例准备好后,执行完函数不结束,而是返回继续等待下一次函数被调用。即使 FaaS 是常驻进程型,如果一段时间没有事件触发,函数实例还是会被云服务商销毁。

★从下面这张图其实可以看到触发器就是一个常驻进程模型,只不过这个触发器由云服务商处理罢了。

%title插图%num

假设我们要部署的是一个 MVC 架构的 Web 服务,那么:

  • 在之前,假设没有 FaaS,我们要将应用部署到托管平台 PaaS 上;启动 Web 服务时,主进程初始化连接 MongoDB,初始化完成后,持续监听服务器的 80 端口,直到监听端口的句柄关闭或主进程接收到终止信号;当 80 端口和客户端建立完 TCP 链接,有 HTTP 请求过来,服务器就会将请求转发给 Web 服务的主进程,这时主进程会创建一个子进程来处理这个请求。
  • 而在 FaaS 常驻进程型模式下,首先我们要改造一下代码,Node.js 的 Server 对象采用 FaaS Runtime 提供的 Server 对象;然后我们把监听端口改为监听 HTTP 事件;启动 Web 服务时,主进程初始化连接 MongoDB,初始化完成后,持续监听 HTTP 事件,直到被云服务商控制的父进程关闭回收。

    当 HTTP 事件发生时,我们的 Web 服务主进程跟之前一样,创建一个子进程来处理这个请求事件。主进程就如我们上图中绘制的那个蓝色的圆点,当 HTTP 事件发生时,它创建的子进程就是蓝色弧形箭头,当子进程处理完后就会被主进程回收。

通过上面的例子,可以看到:常驻进程型就是为了传统 MVC 架构部署上 FaaS 专门设计的(显得很不自然,FaaS 原生的还是用完即毁型)。当然也可以使用用完即毁型来部署 MVC 架构的 Web 服务,但是不推荐这么做,因为用完即毁型对传统 MVC 改造的成本太大。

★从可控性和改造成本角度来看 Web 服务,服务端部署方案*适合的还是托管平台 PaaS 或者自己搭服务跑在 IaaS 上。正如我上一讲所说,使用 FaaS 就必须在 FaaS 的条件限制内使用,*佳的做法应该是一开始就选用 FaaS 开发。

用完即毁型适用的场景:数据编排和服务编排。

数据编排

目前*成功*广泛的设计模式就是 MVC 模式。但随着前端 MVVM 框架越来越火,前端 View 层逐渐前置,发展成 SPA 单页应用;后端 Control 和 Model 层逐渐下沉,发展成面向服务编程的后端应用。这种情况下,前后端更加彻底地解耦了,前端开发可以依赖 Mock 数据接口完全脱离后端限制,而后端的同学则可以面向数据接口开发,但这也产生了高网络 I/O 的数据网关层。

Node.js 的异步非阻塞和 JavaScript 天然亲近前端工程师的特性,自然地接过数据网关层。因此诞生了 Node.js 的 BFF 层 (Backend For Frontend),BFF 层充当了中间胶水层的角色,粘合前后端。将后端数据和后端接口编排,适配成前端需要的数据结构,提供给前端使用。

★未经加工的数据,我们称为元数据 Raw Data,对于普通用户来说元数据几乎不可读。所以我们需要将有用的数据组合起来,并且加工数据,让数据具备价值。对于数据的组合和加工,我们称之为数据编排。

%title插图%num

BFF 层通常是由善于处理高网络 I/O 的 Node.js 应用负责。传统的服务端运维 Node.js 应用还是比较重的,需要我们购买虚拟机,或者使用应用托管 PaaS 平台。但是,由于 BFF 层只是做无状态的数据编排,所以我们完全可以用 FaaS 用完即毁型模型替换掉 BFF 层的 Node.js 应用,也就是*近圈子里老说的 SFF(Serverless For Frontend)。

现在我们再串下新的请求链路逻辑。前端的一个数据请求过来,函数触发器触发我们的函数服务;我们的函数启动后,调用后端提供的元数据接口,并将返回的元数据加工成前端需要的数据格式;我们的 FaaS 函数完全就可以休息了。

%title插图%num

服务编排

服务编排和数据编排很像,主要区别是服务编排是对云服务商提供的各种服务进行组合和加工(也就是说服务商提供了一些 API ,我们对这些 API 进行整合来实现我们想要的功能)。

在 FaaS 出现之前,就有服务编排的概念,但服务编排受限于服务支持的 SDK 语言版本。我们要使用这些服务或 API,都要通过自己熟悉的编程语言去找对应的 SDK,在自己的代码中加载 SDK,使用秘钥调用 SDK 方法进行编排。如果没有 SDK,则需要自己根据平台提供的接口或协议实现 SDK。

但是,有了 FaaS 之后,我们就方便很多了。假如我们服务商没有给我们提供我们熟悉的语言的 SDK,那么我们可以使用其他语言编写一个编排的程序,这个编排的程序会对服务商的服务进行编排。之后,我们再去调用这个编排的程序即可,而这个编排的程序就可以使用用完即毁的方式。比如,我们的 Web 服务需要发送验证码邮件。我们查看阿里云的邮件服务文档,发现阿里云只提供了 Java、PHP 和 Python 的 SDK,而没有 Node.js 的 SDK。这个时候,我们可以参考邮件服务的 PHP 文档,就用 PHP 的 SDK 创建一个 FaaS 服务来发送邮件(发送邮件的功能是很单一的)。

这个也是 FaaS 的一个亮点:语言无关性。它意味着你的团队不再局限于单一的开发语言了,你们可以利用 Java、PHP、Python、Node.js 各自的语言优势,混合开发出复杂的应用。FaaS 服务编排被云服务商特别关注正是因为它具备的这种开放性。使用 FaaS 可以创造出各种各样复杂的服务编排场景,而且还与语言无关,这大大增加了云服务商各种服务的使用场景。当然,这对开发者也提出了要求,它要求开发者去更多地了解云服务商提供的各种服务。

Serverless 在 SaaS 领域的*佳实践

随着互联网人口红利逐渐减弱,基于流量的增长已经放缓,互联网行业迫切需要找到一片足以承载自身持续增长的新蓝海,产业互联网正是这一宏大背景下的新趋势。我们看到互联网浪潮正在席卷传统行业,云计算、大数据、人工智能开始大规模融入到金融、制造、物流、零售、文娱、教育、医疗等行业的生产环节中,这种融合称为产业互联网。

a而在产业互联网中,有一块不可小觑的领域是 SaaS 领域,它是 ToB 赛道的中间力量,比如 CRM、HRM、费控系统、财务系统、协同办公等等。

%title插图%num

SaaS 系统面临的挑战

在消费互联网时代,大家是搜索想要的东西,各个厂商在云计算、大数据、人工智能等技术基座之上建立流量*大化的服务与生态,基于海量内容分发与流量共享为逻辑构建系统。而到了产业互联网时代,供给关系发生了变化,大家是定制想要的东西,需要从供给与需求两侧出发进行双向建设,这个时候系统的灵活性和扩展性面临着前所未有的挑战,尤其是 ToB 的 SaaS 领域。

%title插图%num

特别对于当下的经济环境,SaaS 厂商要明白,不能再通过烧钱的方式,只关注在自己的用户数量上,而更多的要思考如何帮助客户降低成本、增加效率,所以需要将更多的精力放在自己产品的定制化能力上。

%title插图%num

%title插图%num

如何应对挑战

SaaS 领域中的佼佼者 Salesforce,将 CRM 的概念扩展到 Marketing、Sales、Service,而这三块领域中只有 Sales 有专门的 SaaS 产品,其他两个领域都是各个 ISV 在不同行业的行业解决方案,靠的是什么?毋庸置疑,是 Salesforce 强大的 aPaaS 平台。ISV、内部实施、客户均可以在各自维度通过 aPaaS 平台构建自己行业、自己领域的 SaaS 系统,建立完整的生态。所以在我看来,现在的 Salesforce 已经由一家 SaaS 公司升华为一家 aPaaS 平台公司了。这种演进的过程也印证了消费互联网和产业互联网的转换逻辑以及后者的核心诉求。

然而不是所有 SaaS 公司都有财力和时间去孵化和打磨自己的 aPaaS 平台,但市场的变化、用户的诉求是实实在在存在的。若要生存,就要求变。这个变的核心就是能够让自己目前的 SaaS 系统变得灵活起来,相对建设困难的 aPaaS 平台,我们其实可以选择轻量且有效的 Serverless 方案来提升现有系统的灵活性和可扩展性,从而实现用户不同的定制需求。

%title插图%num

Serverless工作流

Serverless 工作流是一个用来协调多个分布式任务执行的全托管云服务。在 Serverless工作流中,可以用顺序、分支、并行等方式来编排分布式任务,Serverless 工作流会按照设定好的步骤可靠地协调任务执行,跟踪每个任务的状态转换,并在必要时执行您定义的重试逻辑,以确保工作流顺利完成。Serverless 工作流通过提供日志记录和审计来监视工作流的执行,可以轻松地诊断和调试应用。

下面这张图描述了 Serverless 工作流如何协调分布式任务,这些任务可以是函数、已集成云服务API、运行在虚拟机或容器上的程序。

%title插图%num

看完 Serverless 工作流的介绍,大家可能已经多少有点思路了吧。系统灵活性和可扩展性的核心是服务可编排,无论是以前的BPM还是现在的 aPaaS。所以基于 Serverless 工作流重构SaaS系统灵活性方案的核心思路,是将系统内用户*希望定制的功能进行梳理、拆分、抽离,再配合函数计算(FC)提供无状态的能力,通过 Serverless 工作流进行这些功能点的编排,从而实现不同的业务流程。

%title插图%num

通过函数计算 FC 和 Serverless 工作流搭建灵活的订餐模块

订餐场景相信大家都不会陌生,在家叫外卖或者在餐馆点餐,都涉及到这个场景。当下也有很多提供点餐系统的 SaaS 服务厂商,有很多不错的 SaaS 点餐系统。

随着消费互联网向产业互联网转换,这些 SaaS 点餐系统面临的定制化的需求也越来越多,其中有一个需求是不同的商家在支付时会显示不同的支付方式,比如从A商家点餐后付款时显示支付宝、微信支付、银联支付,从B商家点餐后付款时显示支付宝、京东支付。突然美团又冒出来了美团支付,此时B商家接了美团支付,那么从B商家点餐后付款时显示支付宝、京东支付、美团支付。

诸如此类的定制化需求越来越多,这些 SaaS 产品如果没有 PaaS 平台,那么就会疲于不断的通过硬代码增加条件判断来实现不同商家的需求,这显然不是一个可持续发展的模式。

那么我们来看看通过函数计算 FC 和 Serverless 工作流如何优雅的解决这个问题。先来看看这个点餐流程:

%title插图%num

通过Serverless工作流创建流程

首选我需要将上面用户侧的流程转变为程序侧的流程,此时就需要使用 Serverless 工作流来担任此任务了。

打开 Serverless 控制台,创建订餐流程,这里 Serverless 工作流使用流程定义语言 FDL 创建工作流,如何使用FDL创建工作流请参阅文档。流程图如下图所示:

%title插图%num

FDL 代码为:

  1. 1version: v1beta1
  2. 2type: flow
  3. 3timeoutSeconds: 3600
  4. 4steps:
  5. 5  – type: task
  6. 6    name: generateInfo
  7. 7    timeoutSeconds: 300
  8. 8    resourceArn: acs:mns:::/topics/generateInfo-fnf-demo-jiyuan/messages
  9. 9    pattern: waitForCallback
  10. 10    inputMappings:
  11. 11      – target: taskToken
  12. 12        source: $context.task.token
  13. 13      – target: products
  14. 14        source: $input.products
  15. 15      – target: supplier
  16. 16        source: $input.supplier
  17. 17      – target: address
  18. 18        source: $input.address
  19. 19      – target: orderNum
  20. 20        source: $input.orderNum
  21. 21      – target: type
  22. 22        source: $context.step.name
  23. 23    outputMappings:
  24. 24      – target: paymentcombination
  25. 25        source: $local.paymentcombination
  26. 26      – target: orderNum
  27. 27        source: $local.orderNum
  28. 28    serviceParams:
  29. 29      MessageBody: $
  30. 30      Priority: 1
  31. 31    catch:
  32. 32      – errors:
  33. 33          – FnF.TaskTimeout
  34. 34        goto: orderCanceled
  35. 35  -type: task
  36. 36    name: payment
  37. 37    timeoutSeconds: 300
  38. 38    resourceArn: acs:mns:::/topics/payment-fnf-demo-jiyuan/messages
  39. 39    pattern: waitForCallback
  40. 40    inputMappings:
  41. 41      – target: taskToken
  42. 42        source: $context.task.token
  43. 43      – target: orderNum
  44. 44        source: $local.orderNum
  45. 45      – target: paymentcombination
  46. 46        source: $local.paymentcombination
  47. 47      – target: type
  48. 48        source: $context.step.name
  49. 49    outputMappings:
  50. 50      – target: paymentMethod
  51. 51        source: $local.paymentMethod
  52. 52      – target: orderNum
  53. 53        source: $local.orderNum
  54. 54      – target: price
  55. 55        source: $local.price
  56. 56      – target: taskToken
  57. 57        source: $input.taskToken
  58. 58    serviceParams:
  59. 59      MessageBody: $
  60. 60      Priority: 1
  61. 61    catch:
  62. 62      – errors:
  63. 63          – FnF.TaskTimeout
  64. 64        goto: orderCanceled
  65. 65  – type: choice
  66. 66    name: paymentCombination
  67. 67    inputMappings:
  68. 68      – target: orderNum
  69. 69        source: $local.orderNum
  70. 70      – target: paymentMethod
  71. 71        source: $local.paymentMethod
  72. 72      – target: price
  73. 73        source: $local.price
  74. 74      – target: taskToken
  75. 75        source: $local.taskToken
  76. 76    choices:
  77. 77      – condition: $.paymentMethod == “zhifubao”
  78. 78        steps:
  79. 79          – type: task
  80. 80            name: zhifubao
  81. 81            resourceArn: acs:fc:cn-hangzhou:your_account_id:services/FNFDemo-jiyuan/functions/zhifubao-fnf-demo
  82. 82            inputMappings:
  83. 83              – target: price
  84. 84                source: $input.price
  85. 85              – target: orderNum
  86. 86                source: $input.orderNum
  87. 87              – target: paymentMethod
  88. 88                source: $input.paymentMethod
  89. 89              – target: taskToken
  90. 90                source: $input.taskToken
  91. 91      – condition: $.paymentMethod == “weixin”
  92. 92        steps:
  93. 93          – type: task
  94. 94            name: weixin
  95. 95            resourceArn: acs:fc:cn-hangzhou:your_account_id:services/FNFDemo-jiyuan.LATEST/functions/weixin-fnf-demo
  96. 96            inputMappings:
  97. 97            – target: price
  98. 98              source: $input.price
  99. 99            – target: orderNum
  100. 100              source: $input.orderNum
  101. 101            – target: paymentMethod
  102. 102              source: $input.paymentMethod
  103. 103            – target: taskToken
  104. 104              source: $input.taskToken
  105. 105      – condition: $.paymentMethod == “unionpay”
  106. 106        steps:
  107. 107          – type: task
  108. 108            name: unionpay
  109. 109            resourceArn: acs:fc:cn-hangzhou:your_account_id:services/FNFDemo-jiyuan.LATEST/functions/union-fnf-demo
  110. 110            inputMappings:
  111. 111            – target: price
  112. 112              source: $input.price
  113. 113            – target: orderNum
  114. 114              source: $input.orderNum
  115. 115            – target: paymentMethod
  116. 116              source: $input.paymentMethod
  117. 117            – target: taskToken
  118. 118              source: $input.taskToken
  119. 119    default:
  120. 120      goto: orderCanceled
  121. 121  – type: task
  122. 122    name: orderCompleted
  123. 123    resourceArn: acs:fc:cn-hangzhou:your_account_id:services/FNFDemo-jiyuan.LATEST/functions/orderCompleted
  124. 124    end: true
  125. 125  – type: task
  126. 126    name: orderCanceled
  127. 127    resourceArn: acs:fc:cn-hangzhou:your_account_id:services/FNFDemo-jiyuan.LATEST/functions/cancerOrde

在解析整个流程之前,我先要说明的一点是,我们不是完全通过 Serverless 函数计算和 Serverless 工作流来搭建订餐模块,只是用它来解决灵活性的问题,所以这个示例的主体应用是Java编写的,然后结合了 Serverless 函数计算和 Serverless 工作流。下面我们来详细解析这个流程。

启动流程

按常理,开始点餐时流程就应该启动了,所以在这个示例中,我的设计是当我们选择完商品和商家、填完地址后启动流程:

%title插图%num

这里我们通过 Serverless 工作流提供的 OpenAPI 来启动流程。

%title插图%num

  • Java 启动流程

这个示例我使用 Serverless 工作流的 Java SDK,首先在 POM 文件中添加依赖:

  1. 1<dependency>
  2. 2<groupId>com.aliyun</groupId>
  3. 3<artifactId>aliyun-java-sdk-core</artifactId>
  4. 4<version>[4.3.2,5.0.0)</version>
  5. 5</dependency>
  6. 6<dependency>
  7. 7<groupId>com.aliyun</groupId>
  8. 8<artifactId>aliyun-java-sdk-fnf</artifactId>
  9. 9<version>[1.0.0,5.0.0)</version>
  10. 10</dependency>

然后创建初始化 Java SDK 的 Config 类:

  1. 1@Configuration
  2. 2public class FNFConfig {
  3. 3
  4. 4@Bean
  5. 5public IAcsClient createDefaultAcsClient(){
  6. 6        DefaultProfile profile = DefaultProfile.getProfile(
  7. 7“cn-xxx”,          // 地域ID
  8. 8“ak”,      // RAM 账号的AccessKey ID
  9. 9“sk”); // RAM 账号Access Key Secret
  10. 10        IAcsClient client = new DefaultAcsClient(profile);
  11. 11return client;
  12. 12    }
  13. 13
  14. 14}

再来看 Controller 中的 startFNF 方法,该方法暴露 GET 方式的接口,传入三个参数:

1、fnfname:要启动的流程名称。

2、execuname:流程启动后的流程实例名称。

3、input:启动输入参数,比如业务参数。

  1. 1@GetMapping(“/startFNF/{fnfname}/{execuname}/{input}”)
  2. 2public StartExecutionResponse startFNF(@PathVariable(“fnfname”String fnfName,
  3. 3@PathVariable(“execuname”String execuName,
  4. 4@PathVariable(“input”String inputStr) throws ClientException {
  5. 5        JSONObject jsonObject = new JSONObject();
  6. 6        jsonObject.put(“fnfname”, fnfName);
  7. 7        jsonObject.put(“execuname”, execuName);
  8. 8        jsonObject.put(“input”, inputStr);
  9. 9return fnfService.startFNF(jsonObject);
  10. 10    }}

再来看 Service 中的 startFNF 方法,该方法分两部分,*个部分是启动流程,第二部分是创建订单对象,并模拟入库(示例中是放在 Map 里了):

  1. 1 @Override
  2. 2public StartExecutionResponse startFNF(JSONObject jsonObject) throws ClientException {
  3. 3        StartExecutionRequest request = new StartExecutionRequest();
  4. 4        String orderNum = jsonObject.getString(“execuname”);
  5. 5        request.setFlowName(jsonObject.getString(“fnfname”));
  6. 6        request.setExecutionName(orderNum);
  7. 7        request.setInput(jsonObject.getString(“input”));
  8. 8
  9. 9        JSONObject inputObj = jsonObject.getJSONObject(“input”);
  10. 10        Order order = new Order();
  11. 11        order.setOrderNum(orderNum);
  12. 12        order.setAddress(inputObj.getString(“address”));
  13. 13        order.setProducts(inputObj.getString(“products”));
  14. 14        order.setSupplier(inputObj.getString(“supplier”));
  15. 15        orderMap.put(orderNum, order);
  16. 16
  17. 17return iAcsClient.getAcsResponse(request);
  18. 18    }

启动流程时,流程名称和启动流程实例的名称是需要传入的参数,这里我将每次的订单编号作为启动流程的实例名称。至于 Input,可以根据需求构造 JSON 字符串传入。这里我将商品、商家、地址、订单号构造了 JSON 字符串在流程启动时传入流程中。

另外,创建了此次订单的 Order 实例,并存在 Map 中,模拟入库,后续环节还会查询该订单实例更新订单属性。

  • VUE 选择商品/商家页面

前端我使用 VUE 搭建,当点击选择商品和商家页面中的下一步后,通过 GET 方式调用 HTTP 协议的接口/startFNF/{fnfname}/{execuname}/{input}。和上面的 Java 方法对应。

1、fnfname:要启动的流程名称。

2、execuname:随机生成 uuid,作为订单的编号,也作为启动流程实例的名称。

3、input:将商品、商家、订单号、地址构建为 JSON 字符串传入流程。

  1. 1            submitOrder(){
  2. 2const orderNum = uuid.v1()
  3. 3this.$axios.$get(‘/startFNF/OrderDemo-Jiyuan/’+orderNum+‘/{\n’ +
  4. 4‘  “products”: “‘+this.products+‘”,\n’ +
  5. 5‘  “supplier”: “‘+this.supplier+‘”,\n’ +
  6. 6‘  “orderNum”: “‘+orderNum+‘”,\n’ +
  7. 7‘  “address”: “‘+this.address+‘”\n’ +
  8. 8‘}’ ).then((response) => {
  9. 9                    console.log(response)
  10. 10if(response.message == “success”){
  11. 11this.$router.push(‘/orderdemo/’ + orderNum)
  12. 12                    }
  13. 13                })
  14. 14            }

generateInfo 节点

*个节点 generateInfo,先来看看 FDL 的含义:

  1. 1 – type: task
  2. 2name: generateInfo
  3. 3timeoutSeconds: 300
  4. 4resourceArn: acs:mns:::/topics/generateInfo-fnf-demo-jiyuan/messages
  5. 5pattern: waitForCallback
  6. 6inputMappings:
  7. 7      – target: taskToken
  8. 8source: $context.task.token
  9. 9      – target: products
  10. 10source: $input.products
  11. 11      – target: supplier
  12. 12source: $input.supplier
  13. 13      – target: address
  14. 14source: $input.address
  15. 15      – target: orderNum
  16. 16source: $input.orderNum
  17. 17      – target: type
  18. 18source: $context.step.name
  19. 19outputMappings:
  20. 20      – target: paymentcombination
  21. 21source: $local.paymentcombination
  22. 22      – target: orderNum
  23. 23source: $local.orderNum
  24. 24serviceParams:
  25. 25MessageBody: $
  26. 26Priority: 1
  27. 27catch:
  28. 28      – errors:
  29. 29          – FnF.TaskTimeout
  30. 30goto: orderCanceled

1、name:节点名称。

2、timeoutSeconds:超时时间。该节点等待的时长,超过时间后会跳转到 goto 分支指向的 orderCanceled 节点。

3、pattern:设置为 waitForCallback,表示需要等待确认。inputMappings:该节点入参。

  • taskToken:Serverless 工作流自动生成的 Token。
  • products:选择的商品。
  • supplier:选择的商家。
  • address:送餐地址。
  • orderNum:订单号。

4、outputMappings:该节点的出参。

  • paymentcombination:该商家支持的支付方式。
  • orderNum:订单号。

5、catch:捕获异常,跳转到其他分支。

这里 resourceArn 和 serviceParams 需要拿出来单独解释。Serverless 工作流支持与多个云服务集成,即将其他服务作为任务步骤的执行单元。服务集成方式由 FDL 语言表达,在任务步骤中,可以使用 resourceArn 来定义集成的目标服务,使用 pattern 定义集成模式。所以可以看到在 resourceArn 中配置 acs:mns:::/topics/generateInfo-fnf-demo-jiyuan/messages 信息,即在 generateInfo 节点中集成了 MNS 消息队列服务,当 generateInfo 节点触发后会向 generateInfo-fnf-demo-jiyuanTopic 中发送一条消息。那么消息正文和参数则在 serviceParams 对象中指定。MessageBody 是消息正文,配置$表示通过输入映射 inputMappings 产生消息正文。

看完*个节点的示例,大家可以看到,在 Serverless 工作流中,节点之间的信息传递可以通过集成 MNS 发送消息来传递,也是使用比较广泛的方式之一。

generateInfo-fnf-demo 函数

向 generateInfo-fnf-demo-jiyuanTopic 中发送的这条消息包含了商品信息、商家信息、地址、订单号,表示一个下订单流程的开始,既然有发消息,那么必然有接受消息进行后续处理。所以打开函数计算控制台,创建服务,在服务下创建名为 generateInfo-fnf-demo 的事件触发器函数,这里选择 Python Runtime:

%title插图%num

创建 MNS 触发器,选择监听 generateInfo-fnf-demo-jiyuanTopic。

%title插图%num

打开消息服务 MNS 控制台,创建 generateInfo-fnf-demo-jiyuanTopic:

%title插图%num

做好函数的准备工作,我们来开始写代码:

  1. 1# -*- coding: utf-8 -*-
  2. 2import logging
  3. 3import json
  4. 4import time
  5. 5import requests
  6. 6from aliyunsdkcore.client import AcsClient
  7. 7from aliyunsdkcore.acs_exception.exceptions import ServerException
  8. 8from aliyunsdkfnf.request.v20190315 import ReportTaskSucceededRequest
  9. 9from aliyunsdkfnf.request.v20190315 import ReportTaskFailedRequest
  10. 10
  11. 11
  12. 12def handler(event, context):
  13. 13# 1. 构建Serverless工作流Client
  14. 14    region = “cn-hangzhou”
  15. 15    account_id = “XXXX”
  16. 16    ak_id = “XXX”
  17. 17    ak_secret = “XXX”
  18. 18    fnf_client = AcsClient(
  19. 19        ak_id,
  20. 20        ak_secret,
  21. 21        region
  22. 22    )
  23. 23    logger = logging.getLogger()
  24. 24# 2. event内的信息即接受到Topic generateInfo-fnf-demo-jiyuan中的消息内容,将其转换为Json对象
  25. 25    bodyJson = json.loads(event)
  26. 26    logger.info(“products:” + bodyJson[“products”])
  27. 27    logger.info(“supplier:” + bodyJson[“supplier”])
  28. 28    logger.info(“address:” + bodyJson[“address”])
  29. 29    logger.info(“taskToken:” + bodyJson[“taskToken”])
  30. 30    supplier = bodyJson[“supplier”]
  31. 31    taskToken = bodyJson[“taskToken”]
  32. 32    orderNum = bodyJson[“orderNum”]
  33. 33# 3. 判断什么商家使用什么样的支付方式组合,这里的示例比较简单粗暴,正常情况下,应该使用元数据配置的方式获取
  34. 34    paymentcombination = “”
  35. 35if supplier == “haidilao”:
  36. 36        paymentcombination = “zhifubao,weixin”
  37. 37else:
  38. 38        paymentcombination = “zhifubao,weixin,unionpay”
  39. 39
  40. 40# 4. 调用Java服务暴露的接口,更新订单信息,主要是更新支付方式
  41. 41    url = “http://xx.xx.xx.xx:8080/setPaymentCombination/” + orderNum + “/” + paymentcombination + “/0”
  42. 42    x = requests.get(url)
  43. 43
  44. 44# 5. 给予generateInfo节点响应,并返回数据,这里返回了订单号和支付方式
  45. 45    output = “{\”orderNum\”: \”%s\”, \”paymentcombination\”:\”%s\” “ \
  46. 46“}” % (orderNum, paymentcombination)
  47. 47    request = ReportTaskSucceededRequest.ReportTaskSucceededRequest()
  48. 48    request.set_Output(output)
  49. 49    request.set_TaskToken(taskToken)
  50. 50    resp = fnf_client.do_action_with_exception(request)
  51. 51return ‘hello world’

因为 generateInfo-fnf-demo 函数配置了MNS触发器,所以当 TopicgenerateInfo-fnf-demo-jiyuan 有消息后就会触发执行 generateInfo-fnf-demo 函数。

整个代码分五部分:

1、构建 Serverless 工作流 Client。

2、event 内的信息即接受到 TopicgenerateInfo-fnf-demo-jiyuan 中的消息内容,将其转换为 Json 对象。

3、判断什么商家使用什么样的支付方式组合,这里的示例比较简单粗暴,正常情况下,应该使用元数据配置的方式获取。比如在系统内有商家信息的配置功能,通过在界面上配置该商家支持哪些支付方式,形成元数据配置信息,提供查询接口,在这里进行查询。

4、调用Java服务暴露的接口,更新订单信息,主要是更新支付方式。

5、给予 generateInfo 节点响应,并返回数据,这里返回了订单号和支付方式。因为该节点的 pattern 是 waitForCallback,所以需要等待响应结果。

payment节点

我们再来看第二个节点 payment,先来看 FDL 代码:

  1. 1– type: task
  2. 2name: payment
  3. 3timeoutSeconds: 300
  4. 4resourceArn: acs:mns:::/topics/payment-fnf-demo-jiyuan/messages
  5. 5pattern: waitForCallback
  6. 6inputMappings:
  7. 7      – target: taskToken
  8. 8source: $context.task.token
  9. 9      – target: orderNum
  10. 10source: $local.orderNum
  11. 11      – target: paymentcombination
  12. 12source: $local.paymentcombination
  13. 13      – target: type
  14. 14source: $context.step.name
  15. 15outputMappings:
  16. 16      – target: paymentMethod
  17. 17source: $local.paymentMethod
  18. 18      – target: orderNum
  19. 19source: $local.orderNum
  20. 20      – target: price
  21. 21source: $local.price
  22. 22      – target: taskToken
  23. 23source: $input.taskToken
  24. 24serviceParams:
  25. 25MessageBody: $
  26. 26Priority: 1
  27. 27catch:
  28. 28      – errors:
  29. 29          – FnF.TaskTimeout
  30. 30goto: orderCanceled

当流程流转到 payment 节点后,意味着用户进入了支付页面。

%title插图%num

这时 payment 节点会向 MNS 的 Topicpayment-fnf-demo-jiyuan 发送消息,会触发 payment-fnf-demo 函数。

payment-fnf-demo函数

payment-fnf-demo 函数的创建方式和 generateInfo-fnf-demo 函数类似,这里不再累赘。我们直接来看代码:

  1. 1# -*- coding: utf-8 -*-
  2. 2import logging
  3. 3import json
  4. 4import os
  5. 5import time
  6. 6import logging
  7. 7from aliyunsdkcore.client import AcsClient
  8. 8from aliyunsdkcore.acs_exception.exceptions import ServerException
  9. 9from aliyunsdkcore.client import AcsClient
  10. 10from aliyunsdkfnf.request.v20190315 import ReportTaskSucceededRequest
  11. 11from aliyunsdkfnf.request.v20190315 import ReportTaskFailedRequest
  12. 12from mns.account import Account  # pip install aliyun-mns
  13. 13from mns.queue import *
  14. 14
  15. 15
  16. 16def handler(event, context):
  17. 17    logger = logging.getLogger()
  18. 18    region = “xxx”
  19. 19    account_id = “xxx”
  20. 20    ak_id = “xxx”
  21. 21    ak_secret = “xxx”
  22. 22    mns_endpoint = “http://your_account_id.mns.cn-hangzhou.aliyuncs.com/”
  23. 23    queue_name = “payment-queue-fnf-demo”
  24. 24    my_account = Account(mns_endpoint, ak_id, ak_secret)
  25. 25    my_queue = my_account.get_queue(queue_name)
  26. 26# my_queue.set_encoding(False)
  27. 27    fnf_client = AcsClient(
  28. 28        ak_id,
  29. 29        ak_secret,
  30. 30        region
  31. 31    )
  32. 32    eventJson = json.loads(event)
  33. 33
  34. 34    isLoop = True
  35. 35while isLoop:
  36. 36try:
  37. 37            recv_msg = my_queue.receive_message(30)
  38. 38            isLoop = False
  39. 39# body = json.loads(recv_msg.message_body)
  40. 40            logger.info(“recv_msg.message_body:======================” + recv_msg.message_body)
  41. 41            msgJson = json.loads(recv_msg.message_body)
  42. 42            my_queue.delete_message(recv_msg.receipt_handle)
  43. 43# orderCode = int(time.time())
  44. 44            task_token = eventJson[“taskToken”]
  45. 45            orderNum = eventJson[“orderNum”]
  46. 46            output = “{\”orderNum\”: \”%s\”, \”paymentMethod\”: \”%s\”, \”price\”: \”%s\” “ \
  47. 47“}” % (orderNum, msgJson[“paymentMethod”], msgJson[“price”])
  48. 48            request = ReportTaskSucceededRequest.ReportTaskSucceededRequest()
  49. 49            request.set_Output(output)
  50. 50            request.set_TaskToken(task_token)
  51. 51            resp = fnf_client.do_action_with_exception(request)
  52. 52except Exception as e:
  53. 53            logger.info(“new loop”)
  54. 54return ‘hello world’

该函数的核心思路是等待用户在支付页面选择某个支付方式确认支付。所以这里使用了 MNS 的队列来模拟等待。循环等待接收队列 payment-queue-fnf-demo 中的消息,当收到消息后将订单号和用户选择的具体支付方式以及金额返回给 payment 节点。

VUE选择支付方式页面

因为经过 generateInfo 节点后,该订单的支付方式信息已经有了,所以对于用户而言,当填完商品、商家、地址后,跳转到的页面就是该确认支付页面,并且包含了该商家支持的支付方式。

%title插图%num

当进入该页面后,会请求 Java 服务暴露的接口,获取订单信息,根据支付方式在页面上显示不同的支付方式。代码片段如下:

%title插图%num

当用户选定某个支付方式点击提交订单按钮后,向 payment-queue-fnf-demo 队列发送消息,即通知 payment-fnf-demo 函数继续后续的逻辑。

这里我使用了一个 HTTP 触发器类型的函数,用于实现向 MNS 发消息的逻辑,paymentMethod-fnf-demo 函数代码如下。

  1. 1# -*- coding: utf-8 -*-
  2. 2
  3. 3import logging
  4. 4import urllib.parse
  5. 5import json
  6. 6from mns.account import Account  # pip install aliyun-mns
  7. 7from mns.queue import *
  8. 8HELLO_WORLD = b’Hello world!\n’
  9. 9
  10. 10def handler(environ, start_response):
  11. 11    logger = logging.getLogger()
  12. 12    context = environ[‘fc.context’]
  13. 13    request_uri = environ[‘fc.request_uri’]
  14. 14for k, v in environ.items():
  15. 15if k.startswith(‘HTTP_’):
  16. 16# process custom request headers
  17. 17pass
  18. 18try:
  19. 19        request_body_size = int(environ.get(‘CONTENT_LENGTH’0))
  20. 20except (ValueError):
  21. 21        request_body_size = 0
  22. 22    request_body = environ[‘wsgi.input’].read(request_body_size)
  23. 23    paymentMethod = urllib.parse.unquote(request_body.decode(“GBK”))
  24. 24    logger.info(paymentMethod)
  25. 25    paymentMethodJson = json.loads(paymentMethod)
  26. 26
  27. 27    region = “cn-xxx”
  28. 28    account_id = “xxx”
  29. 29    ak_id = “xxx”
  30. 30    ak_secret = “xxx”
  31. 31    mns_endpoint = “http://your_account_id.mns.cn-hangzhou.aliyuncs.com/”
  32. 32    queue_name = “payment-queue-fnf-demo”
  33. 33    my_account = Account(mns_endpoint, ak_id, ak_secret)
  34. 34    my_queue = my_account.get_queue(queue_name)
  35. 35    output = “{\”paymentMethod\”: \”%s\”, \”price\”:\”%s\” “ \
  36. 36“}” % (paymentMethodJson[“paymentMethod”], paymentMethodJson[“price”])
  37. 37    msg = Message(output)
  38. 38    my_queue.send_message(msg)
  39. 39
  40. 40    status = ‘200 OK’
  41. 41    response_headers = [(‘Content-type’‘text/plain’)]
  42. 42    start_response(status, response_headers)
  43. 43return [HELLO_WORLD]

该函数的逻辑很简单,就是向 MNS 的队列 payment-queue-fnf-demo 发送用户选择的支付方式和金额。

VUE代码片段如下:

%title插图%num

paymentCombination 节点

paymentCombination 节点是一个路由节点,通过判断某个参数路由到不同的节点,这里自然使用 paymentMethod 作为判断条件。FDL 代码如下:

  1. 1– type: choice
  2. 2    name: paymentCombination
  3. 3    inputMappings:
  4. 4      – target: orderNum
  5. 5source: $local.orderNum
  6. 6      – target: paymentMethod
  7. 7source: $local.paymentMethod
  8. 8      – target: price
  9. 9source: $local.price
  10. 10      – target: taskToken
  11. 11source: $local.taskToken
  12. 12    choices:
  13. 13      – condition: $.paymentMethod == “zhifubao”
  14. 14        steps:
  15. 15          – type: task
  16. 16            name: zhifubao
  17. 17            resourceArn: acs:fc:cn-hangzhou:your_account_id:services/FNFDemo-jiyuan/functions/zhifubao-fnf-demo
  18. 18            inputMappings:
  19. 19              – target: price
  20. 20source: $input.price
  21. 21              – target: orderNum
  22. 22source: $input.orderNum
  23. 23              – target: paymentMethod
  24. 24source: $input.paymentMethod
  25. 25              – target: taskToken
  26. 26source: $input.taskToken
  27. 27      – condition: $.paymentMethod == “weixin”
  28. 28        steps:
  29. 29          – type: task
  30. 30            name: weixin
  31. 31            resourceArn: acs:fc:cn-hangzhou:your_account_id:services/FNFDemo-jiyuan.LATEST/functions/weixin-fnf-demo
  32. 32            inputMappings:
  33. 33            – target: price
  34. 34source: $input.price
  35. 35            – target: orderNum
  36. 36source: $input.orderNum
  37. 37            – target: paymentMethod
  38. 38source: $input.paymentMethod
  39. 39            – target: taskToken
  40. 40source: $input.taskToken
  41. 41      – condition: $.paymentMethod == “unionpay”
  42. 42        steps:
  43. 43          – type: task
  44. 44            name: unionpay
  45. 45            resourceArn: acs:fc:cn-hangzhou:your_account_id:services/FNFDemo-jiyuan.LATEST/functions/union-fnf-demo
  46. 46            inputMappings:
  47. 47            – target: price
  48. 48source: $input.price
  49. 49            – target: orderNum
  50. 50source: $input.orderNum
  51. 51            – target: paymentMethod
  52. 52source: $input.paymentMethod
  53. 53            – target: taskToken
  54. 54source: $input.taskToken
  55. 55    default:
  56. 56      goto: orderCanceled

这里的流程是,用户选择支付方式后,通过消息发送给 payment-fnf-demo 函数,然后将支付方式返回,于是流转到 paymentCombination 节点通过判断支付方式流转到具体处理支付逻辑的节点和函数。

zhifubao节点

我们具体来看一个 zhifubao 节点:

  1. 1choices:
  2. 2      – condition: $.paymentMethod == “zhifubao”
  3. 3        steps:
  4. 4          – type: task
  5. 5            name: zhifubao
  6. 6            resourceArn: acs:fc:cn-hangzhou:your_account_id:services/FNFDemo-jiyuan/functions/zhifubao-fnf-demo
  7. 7            inputMappings:
  8. 8              – target: price
  9. 9source: $input.price
  10. 10              – target: orderNum
  11. 11source: $input.orderNum
  12. 12              – target: paymentMethod
  13. 13source: $input.paymentMethod
  14. 14              – target: taskToken
  15. 15source: $input.taskToken

这个节点的 resourceArn 和之前两个节点的不同,这里配置的是函数计算中函数的 ARN,也就是说当流程流转到这个节点时会触发 zhifubao-fnf-demo 函数,该函数是一个事件触发函数,但不需要创建任何触发器。流程将订单金额、订单号、支付方式传给 zhifubao-fnf-demo 函数。

zhifubao-fnf-demo函数

现在我们来看zhifubao-fnf-demo函数的代码:

  1. 1# -*- coding: utf-8 -*-
  2. 2import logging
  3. 3import json
  4. 4import requests
  5. 5import urllib.parse
  6. 6from aliyunsdkcore.client import AcsClient
  7. 7from aliyunsdkcore.acs_exception.exceptions import ServerException
  8. 8from aliyunsdkfnf.request.v20190315 import ReportTaskSucceededRequest
  9. 9from aliyunsdkfnf.request.v20190315 import ReportTaskFailedRequest
  10. 10
  11. 11
  12. 12def handler(event, context):
  13. 13  region = “cn-xxx”
  14. 14  account_id = “xxx”
  15. 15  ak_id = “xxx”
  16. 16  ak_secret = “xxx”
  17. 17  fnf_client = AcsClient(
  18. 18    ak_id,
  19. 19    ak_secret,
  20. 20    region
  21. 21  )
  22. 22  logger = logging.getLogger()
  23. 23  logger.info(event)
  24. 24  bodyJson = json.loads(event)
  25. 25  price = bodyJson[“price”]
  26. 26  taskToken = bodyJson[“taskToken”]
  27. 27  orderNum = bodyJson[“orderNum”]
  28. 28  paymentMethod = bodyJson[“paymentMethod”]
  29. 29  logger.info(“price:” + price)
  30. 30  newPrice = int(price) * 0.8
  31. 31  logger.info(“newPrice:” + str(newPrice))
  32. 32  url = “http://xx.xx.xx.xx:8080/setPaymentCombination/” + orderNum + “/” + paymentMethod + “/” + str(newPrice)
  33. 33  x = requests.get(url)
  34. 34
  35. 35return {“Status”:“ok”}

示例中的代码逻辑很简单,接收到金额后,将金额打8折,然后将价格更新回订单。其他支付方式的节点和函数如法炮制,变更实现逻辑就可以。在这个示例中,微信支付打了5折,银联支付打7折。

完整流程

流程中的 orderCompleted 和 orderCanceled 节点没做什么逻辑,大家可以自行发挥,思路和之前的节点一样。所以完整的流程是这样:

%title插图%num

从Serverless工作流中看到的节点流转是这样的:

%title插图%num

%title插图%num

总结

到此,我们基于 Serverless 工作流和 Serverless 函数计算构建的订单模块示例就算完成了,在示例中,有两个点需要大家注意:

1. 配置商家和支付方式的元数据规则。

2. 确认支付页面的元数据规则。

因为在实际生产中,我们需要将可定制的部分都抽象为元数据描述,需要有配置界面制定商家的支付方式即更新元数据规则,然后前端页面基于元数据信息展示相应的内容。

所以如果之后需要接入其他的支付方式,只需在 paymentCombination 路由节点中确定好路由规则,然后增加对应的支付方式函数即可。通过增加元数据配置项,就可以在页面显示新加的支付方式,并且路由到处理新支付方式的函数中。

如何写出让 CPU 跑得更快的代码?

前言

代码都是由 CPU 跑起来的,我们代码写的好与坏就决定了 CPU 的执行效率,特别是在编写计算密集型的程序,更要注重 CPU 的执行效率,否则将会大大影响系统性能。

CPU 内部嵌入了 CPU Cache(高速缓存),它的存储容量很小,但是离 CPU 核心很近,所以缓存的读写速度是*快的,那么如果 CPU 运算时,直接从 CPU Cache 读取数据,而不是从内存的话,运算速度就会很快。

但是,大多数人不知道 CPU Cache 的运行机制,以至于不知道如何才能够写出能够配合 CPU Cache 工作机制的代码,一旦你掌握了它,你写代码的时候,就有新的优化思路了。

那么,接下来我们就来看看,CPU Cache 到底是什么样的,是如何工作的呢,又该写出让 CPU 执行更快的代码呢?

%title插图%num


%title插图%num

CPU Cache 有多快?

你可能会好奇为什么有了内存,还需要 CPU Cache?根据摩尔定律,CPU 的访问速度每 18 个月就会翻倍,相当于每年增长 60% 左右,内存的速度当然也会不断增长,但是增长的速度远小于 CPU,平均每年只增长 7% 左右。于是,CPU 与内存的访问性能的差距不断拉大。

到现在,一次内存访问所需时间是 200~300 多个时钟周期,这意味着 CPU 和内存的访问速度已经相差 200~300 多倍了。

为了弥补 CPU 与内存两者之间的性能差异,就在 CPU 内部引入了  CPU Cache,也称高速缓存。

CPU Cache 通常分为大小不等的三级缓存,分别是 L1 Cache、L2 Cache 和 L3 Cache

由于 CPU Cache 所使用的材料是 SRAM,价格比内存使用的 DRAM 高出很多,在当今每生产 1 MB 大小的 CPU Cache 需要 7 美金的成本,而内存只需要 0.015 美金的成本,成本方面相差了 466 倍,所以 CPU Cache 不像内存那样动辄以 GB 计算,它的大小是以 KB 或 MB 来计算的。

在 Linux 系统中,我们可以使用下图的方式来查看各级 CPU Cache 的大小,比如我这手上这台服务器,离 CPU 核心*近的 L1 Cache 是 32KB,其次是 L2 Cache 是 256KB,*大的 L3 Cache 则是 3MB。

%title插图%num

其中,L1 Cache 通常会分为「数据缓存」和「指令缓存」,这意味着数据和指令在 L1 Cache 这一层是分开缓存的,上图中的 index0 也就是数据缓存,而 index1 则是指令缓存,它两的大小通常是一样的。

另外,你也会注意到,L3 Cache 比 L1 Cache 和 L2 Cache 大很多,这是因为 L1 Cache 和 L2 Cache 都是每个 CPU 核心独有的,而 L3 Cache 是多个 CPU 核心共享的。

程序执行时,会先将内存中的数据加载到共享的 L3 Cache 中,再加载到每个核心独有的 L2 Cache,*后进入到*快的 L1 Cache,之后才会被 CPU 读取。它们之间的层级关系,如下图:

%title插图%num

越靠近 CPU 核心的缓存其访问速度越快,CPU 访问 L1 Cache 只需要 2~4 个时钟周期,访问 L2 Cache 大约 10~20 个时钟周期,访问 L3 Cache 大约 20~60 个时钟周期,而访问内存速度大概在 200~300 个 时钟周期之间。如下表格:

%title插图%num

所以,CPU 从 L1 Cache 读取数据的速度,相比从内存读取的速度,会快 100 多倍。

%title插图%num

CPU Cache 的数据结构和读取过程是什么样的?

CPU Cache 的数据是从内存中读取过来的,它是以一小块一小块读取数据的,而不是按照单个数组元素来读取数据的,在 CPU Cache 中的,这样一小块一小块的数据,称为 Cache Line(缓存块)

你可以在你的 Linux 系统,用下面这种方式来查看 CPU 的 Cache Line,你可以看我服务器的 L1 Cache Line 大小是 64 字节,也就意味着 L1 Cache 一次载入数据的大小是 64 字节

%title插图%num

比如,有一个 int array[100] 的数组,当载入 array[0] 时,由于这个数组元素的大小在内存只占 4 字节,不足 64 字节,CPU 就会顺序加载数组元素到 array[15],意味着 array[0]~array[15] 数组元素都会被缓存在 CPU Cache 中了,因此当下次访问这些数组元素时,会直接从 CPU Cache 读取,而不用再从内存中读取,大大提高了 CPU 读取数据的性能。

事实上,CPU 读取数据的时候,无论数据是否存放到 Cache 中,CPU 都是先访问 Cache,只有当 Cache 中找不到数据时,才会去访问内存,并把内存中的数据读入到 Cache 中,CPU 再从 CPU Cache 读取数据。

%title插图%num

这样的访问机制,跟我们使用「内存作为硬盘的缓存」的逻辑是一样的,如果内存有缓存的数据,则直接返回,否则要访问龟速一般的硬盘。

那 CPU 怎么知道要访问的内存数据,是否在 Cache 里?如果在的话,如何找到 Cache 对应的数据呢?我们从*简单、基础的直接映射 Cache(Direct Mapped Cache 说起,来看看整个 CPU Cache 的数据结构和访问逻辑。

前面,我们提到 CPU 访问内存数据时,是一小块一小块数据读取的,具体这一小块数据的大小,取决于 coherency_line_size 的值,一般 64 字节。在内存中,这一块的数据我们称为内存块(Bock,读取的时候我们要拿到数据所在内存块的地址。

对于直接映射 Cache 采用的策略,就是把内存块的地址始终「映射」在一个 CPU Line(缓存块) 的地址,至于映射关系实现方式,则是使用「取模运算」,取模运算的结果就是内存块地址对应的 CPU Line(缓存块) 的地址。

举个例子,内存共被划分为 32 个内存块,CPU Cache 共有 8 个 CPU Line,假设 CPU 想要访问第 15 号内存块,如果 15 号内存块中的数据已经缓存在 CPU Line 中的话,则是一定映射在 7 号 CPU Line 中,因为 15 % 8 的值是 7。

机智的你肯定发现了,使用取模方式映射的话,就会出现多个内存块对应同一个 CPU Line,比如上面的例子,除了 15 号内存块是映射在 7 号 CPU Line 中,还有 7 号、23 号、31 号内存块都是映射到 7 号 CPU Line 中。

%title插图%num

因此,为了区别不同的内存块,在对应的 CPU Line 中我们还会存储一个组标记(Tag)。这个组标记会记录当前 CPU Line 中存储的数据对应的内存块,我们可以用这个组标记来区分不同的内存块。

除了组标记信息外,CPU Line 还有两个信息:

  • 一个是,从内存加载过来的实际存放数据(Data
  • 另一个是,有效位(Valid bit,它是用来标记对应的 CPU Line 中的数据是否是有效的,如果有效位是 0,无论 CPU Line 中是否有数据,CPU 都会直接访问内存,重新加载数据。

CPU 在从 CPU Cache 读取数据的时候,并不是读取 CPU Line 中的整个数据块,而是读取 CPU 所需要的一个数据片段,这样的数据统称为一个字(Word。那怎么在对应的 CPU Line 中数据块中找到所需的字呢?答案是,需要一个偏移量(Offset)

因此,一个内存的访问地址,包括组标记、CPU Line 索引、偏移量这三种信息,于是 CPU 就能通过这些信息,在 CPU Cache 中找到缓存的数据。而对于 CPU Cache 里的数据结构,则是由索引 + 有效位 + 组标记 + 数据块组成。

%title插图%num

如果内存中的数据已经在 CPU Cahe 中了,那 CPU 访问一个内存地址的时候,会经历这 4 个步骤:

  1. 根据内存地址中索引信息,计算在 CPU Cahe 中的索引,也就是找出对应的 CPU Line 的地址;
  2. 找到对应 CPU Line 后,判断 CPU Line 中的有效位,确认 CPU Line 中数据是否是有效的,如果是无效的,CPU 就会直接访问内存,并重新加载数据,如果数据有效,则往下执行;
  3. 对比内存地址中组标记和 CPU Line 中的组标记,确认 CPU Line 中的数据是我们要访问的内存数据,如果不是的话,CPU 就会直接访问内存,并重新加载数据,如果是的话,则往下执行;
  4. 根据内存地址中偏移量信息,从 CPU Line 的数据块中,读取对应的字。

到这里,相信你对直接映射 Cache 有了一定认识,但其实除了直接映射 Cache 之外,还有其他通过内存地址找到 CPU Cache 中的数据的策略,比如全相连 Cache (Fully Associative Cache)、组相连 Cache (Set Associative Cache)等,这几种策策略的数据结构都比较相似,我们理解流直接映射 Cache 的工作方式,其他的策略如果你有兴趣去看,相信很快就能理解的了。

%title插图%num

如何写出让 CPU 跑得更快的代码?

我们知道 CPU 访问内存的速度,比访问 CPU Cache 的速度慢了 100 多倍,所以如果 CPU 所要操作的数据在 CPU Cache 中的话,这样将会带来很大的性能提升。访问的数据在 CPU Cache 中的话,意味着缓存命中,缓存命中率越高的话,代码的性能就会越好,CPU 也就跑的越快。

于是,「如何写出让 CPU 跑得更快的代码?」这个问题,可以改成「如何写出 CPU 缓存命中率高的代码?」。

在前面我也提到, L1 Cache 通常分为「数据缓存」和「指令缓存」,这是因为 CPU 会别处理数据和指令,比如 1+1=2 这个运算,+ 就是指令,会被放在「指令缓存」中,而输入数字 1 则会被放在「数据缓存」里。

因此,我们要分开来看「数据缓存」和「指令缓存」的缓存命中率

如何提升数据缓存的命中率?

假设要遍历二维数组,有以下两种形式,虽然代码执行结果是一样,但你觉得哪种形式效率*高呢?为什么高呢?

%title插图%num

经过测试,形式一 array[i][j]  执行时间比形式二 array[j][i] 快好几倍。

之所以有这么大的差距,是因为二维数组 array 所占用的内存是连续的,比如长度 N 的指是 2 的话,那么内存中的数组元素的布局顺序是这样的:

%title插图%num

形式一用 array[i][j]  访问数组元素的顺序,正是和内存中数组元素存放的顺序一致。当 CPU 访问 array[0][0] 时,由于该数据不在 Cache 中,于是会「顺序」把跟随其后的 3 个元素从内存中加载到 CPU Cache,这样当 CPU 访问后面的 3 个数组元素时,就能在 CPU Cache 中成功地找到数据,这意味着缓存命中率很高,缓存命中的数据不需要访问内存,这便大大提高了代码的性能。

而如果用形式二的 array[j][i] 来访问,则访问的顺序就是:

%title插图%num

你可以看到,访问的方式跳跃式的,而不是顺序的,那么如果 N 的数值很大,那么操作 array[j][i] 时,是没办法把 array[j+1][i] 也读入到 CPU Cache 中的,既然 array[j+1][i] 没有读取到 CPU Cache,那么就需要从内存读取该数据元素了。很明显,这种不连续性、跳跃式访问数据元素的方式,可能不能充分利用到了 CPU Cache 的特性,从而代码的性能不高。

那访问 array[0][0] 元素时,CPU 具体会一次从内存中加载多少元素到 CPU Cache 呢?这个问题,在前面我们也提到过,这跟 CPU Cache Line 有关,它表示 CPU Cache 一次性能加载数据的大小,可以在 Linux 里通过 coherency_line_size 配置查看 它的大小,通常是 64 个字节。

%title插图%num

也就是说,当 CPU 访问内存数据时,如果数据不在 CPU Cache 中,则会一次性会连续加载 64 字节大小的数据到 CPU Cache,那么当访问 array[0][0] 时,由于该元素不足 64 字节,于是就会往后顺序读取 array[0][0]~array[0][15] 到 CPU Cache 中。顺序访问的 array[i][j] 因为利用了这一特点,所以就会比跳跃式访问的 array[j][i] 要快。

因此,遇到这种遍历数组的情况时,按照内存布局顺序访问,将可以有效的利用 CPU Cache 带来的好处,这样我们代码的性能就会得到很大的提升,

如何提升指令缓存的命中率?

提升数据的缓存命中率的方式,是按照内存布局顺序访问,那针对指令的缓存该如何提升呢?

我们以一个例子来看看,有一个元素为 0 到 100 之间随机数字组成的一维数组:

%title插图%num

接下来,对这个数组做两个操作:

%title插图%num

  • *个操作,循环遍历数组,把小于 50 的数组元素置为 0;
  • 第二个操作,将数组排序;

那么问题来了,你觉得先遍历再排序速度快,还是先排序再遍历速度快呢?

在回答这个问题之前,我们先了解 CPU 的分支预测器。对于 if 条件语句,意味着此时至少可以选择跳转到两段不同的指令执行,也就是 if 还是 else 中的指令。那么,如果分支预测可以预测到接下来要执行 if 里的指令,还是 else 指令的话,就可以「提前」把这些指令放在指令缓存中,这样 CPU 可以直接从 Cache 读取到指令,于是执行速度就会很快

当数组中的元素是随机的,分支预测就无法有效工作,而当数组元素都是顺序的,分支预测器会动态地根据历史命中数据对未来进行预测,这样命中率就会很高。

因此,先排序再遍历速度会更快,这是因为排序之后,数字是从小到大的,那么前几次循环命中 if < 50 的次数会比较多,于是分支预测就会缓存 if 里的 array[i] = 0 指令到 Cache 中,后续 CPU 执行该指令就只需要从 Cache 读取就好了。

如果你肯定代码中的 if 中的表达式判断为 true 的概率比较高,我们可以使用显示分支预测工具,比如在 C/C++ 语言中编译器提供了 likely 和 unlikely 这两种宏,如果 if 条件为 ture 的概率大,则可以用 likely 宏把 if 里的表达式包裹起来,反之用 unlikely 宏。

%title插图%num

实际上,CPU 自身的动态分支预测已经是比较准的了,所以只有当非常确信 CPU 预测的不准,且能够知道实际的概率情况时,才建议使用这两种宏。

如果提升多核 CPU 的缓存命中率?

在单核 CPU,虽然只能执行一个进程,但是操作系统给每个进程分配了一个时间片,时间片用完了,就调度下一个进程,于是各个进程就按时间片交替地占用 CPU,从宏观上看起来各个进程同时在执行。

而现代 CPU 都是多核心的,进程可能在不同 CPU 核心来回切换执行,这对 CPU Cache 不是有利的,虽然 L3 Cache 是多核心之间共享的,但是 L1 和 L2 Cache 都是每个核心独有的,如果一个进程在不同核心来回切换,各个核心的缓存命中率就会受到影响,相反如果进程都在同一个核心上执行,那么其数据的 L1 和 L2 Cache 的缓存命中率可以得到有效提高,缓存命中率高就意味着 CPU 可以减少访问 内存的频率。

当有多个同时执行「计算密集型」的线程,为了防止因为切换到不同的核心,而导致缓存命中率下降的问题,我们可以把线程绑定在某一个 CPU 核心上,这样性能可以得到非常可观的提升。

在 Linux 上提供了 sched_setaffinity 方法,来实现将线程绑定到某个 CPU 核心这一功能。

%title插图%num

%title插图%num

总结

由于随着计算机技术的发展,CPU 与 内存的访问速度相差越来越多,如今差距已经高达好几百倍了,所以 CPU 内部嵌入了 CPU Cache 组件,作为内存与 CPU 之间的缓存层,CPU Cache 由于离 CPU 核心很近,所以访问速度也是非常快的,但由于所需材料成本比较高,它不像内存动辄几个 GB 大小,而是仅有几十 KB 到 MB 大小。

当 CPU 访问数据的时候,先是访问 CPU Cache,如果缓存命中的话,则直接返回数据,就不用每次都从内存读取速度了。因此,缓存命中率越高,代码的性能越好。

但需要注意的是,当 CPU 访问数据时,如果 CPU Cache 没有缓存该数据,则会从内存读取数据,但是并不是只读一个数据,而是一次性读取一块一块的数据存放到 CPU Cache 中,之后才会被 CPU 读取。

内存地址映射到 CPU Cache 地址里的策略有很多种,其中比较简单是直接映射 Cache,它巧妙的把内存地址拆分成「索引 + 组标记 + 偏移量」的方式,使得我们可以将很大的内存地址,映射到很小的 CPU Cache 地址里。

要想写出让 CPU 跑得更快的代码,就需要写出缓存命中率高的代码,CPU L1 Cache 分为数据缓存和指令缓存,因而需要分别提高它们的缓存命中率:

  • 对于数据缓存,我们在遍历数据的时候,应该按照内存布局的顺序操作,这是因为 CPU Cache 是根据 CPU Cache Line 批量操作数据的,所以顺序地操作连续内存数据时,性能能得到有效的提升;
  • 对于指令缓存,有规律的条件分支语句能够让 CPU 的分支预测器发挥作用,进一步提高执行的效率;

另外,对于多核 CPU 系统,线程可能在不同 CPU 核心来回切换,这样各个核心的缓存命中率就会受到影响,于是要想提高进程的缓存命中率,可以考虑把线程绑定 CPU 到某一个 CPU 核心。

js实现oss批量下载文件_jquery批量下载文件

一、此方法火狐有些版本是不支持的

window.location.href = ‘https://*****.oss-cn-**.aliyuncs.com/*********’;

二、为了解决火狐有些版本不支持,可以改成这种方式

window.location=’https://*****.oss-cn-**.aliyuncs.com/*********’;

三、该方法IE和火狐都可以,url表示要下载的文件路径:

function(url){
try {
var elemIF = document.createElement(“iframe”);

elemIF.src = url;

elemIF.style.display = “none”;

document.body.appendChild(elemIF);

} catch (e) {
alert(“下载异常!”);

}

}

四、form表单的形式

downloadFile(url){
var form=$(”

“);
form.attr(“style”,”display:none”);

form.attr(“target”,””);

form.attr(“method”,”get”);

form.attr(“action”,url);

$(“body”).append(form);

form.submit();//表单提交}

}

五、a标签的方式

function(url,name){
var a = document.createElement(“a”);

a.download = name + “.xls”;

a.href = url;

$(“body”).append(a); // 修复firefox中无法触发click

a.click();

$(a).remove();

}

六、假如后台给的文件流

function (formData, url, name) {
return new Promise((resolve, reject) => {
var xhr = new XMLHttpRequest();

xhr.open(“POST”, url, true); // 也可以使用POST方式,根据接口

xhr.responseType = “blob”; // 返回类型blob

// 定义请求完成的处理函数,请求前也可以增加加载框/禁用下载按钮逻辑

xhr.onload = function () {
// 请求完成

if (this.status === 200) {
// 返回200

var blob = this.response;

var reader = new FileReader();

reader.readAsDataURL(blob); // 转换为base64,可以直接放入a表情href

reader.onload = function (e) {
// 转换完成,创建一个a标签用于下载

var a = document.createElement(“a”);

a.download = name + “.xls”;

a.href = e.target.result;

$(“body”).append(a); // 修复firefox中无法触发click

a.click();

resolve(200)

$(a).remove();

};

}

};

// 发送ajax请求

xhr.send(formData);

})

};

七、download.js

我粘贴一下download的源码

//download.js v4.2, by dandavis; 2008-2016. [CCBY2] see http://danml.com/download.html for tests/usage

// v1 landed a FF+Chrome compat way of downloading strings to local un-named files, upgraded to use a hidden frame and optional mime

// v2 added named files via a[download], msSaveBlob, IE (10+) support, and window.URL support for larger+faster saves than dataURLs

// v3 added dataURL and Blob Input, bind-toggle arity, and legacy dataURL fallback was improved with force-download mime and base64 support. 3.1 improved safari handling.

// v4 adds AMD/UMD, commonJS, and plain browser support

// v4.1 adds url download capability via solo URL argument (same domain/CORS only)

// v4.2 adds semantic variable names, long (over 2MB) dataURL support, and hidden by default temp anchors

// https://github.com/rndme/download

(function (root, factory) {
if (typeof define === ‘function’ && define.amd) {
// AMD. Register as an anonymous module.

define([], factory);

} else if (typeof exports === ‘object’) {
// Node. Does not work with strict CommonJS, but

// only CommonJS-like environments that support module.exports,

// like Node.

module.exports = factory();

} else {
// Browser globals (root is window)

root.download = factory();

}

}(this, function () {
return function download(data, strFileName, strMimeType) {
var self = window, // this script is only for browsers anyway…

defaultMime = “application/octet-stream”, // this default mime also triggers iframe downloads

mimeType = strMimeType || defaultMime,

payload = data,

url = !strFileName && !strMimeType && payload,

anchor = document.createElement(“a”),

toString = function(a){return String(a);},

myBlob = (self.Blob || self.MozBlob || self.WebKitBlob || toString),

fileName = strFileName || “download”,

blob,

reader;

myBlob= myBlob.call ? myBlob.bind(self) : Blob ;

if(String(this)===”true”){ //reverse arguments, allowing download.bind(true, “text/xml”, “export.xml”) to act as a callback

payload=[payload, mimeType];

mimeType=payload[0];

payload=payload[1];

}

if(url && url.length< 2048){ // if no filename and no mime, assume a url was passed as the only argument

fileName = url.split(“/”).pop().split(“?”)[0];

anchor.href = url; // assign href prop to temp anchor

if(anchor.href.indexOf(url) !== -1){ // if the browser determines that it’s a potentially valid url path:

var ajax=new XMLHttpRequest();

ajax.open( “GET”, url, true);

ajax.responseType = ‘blob’;

ajax.οnlοad= function(e){
download(e.target.response, fileName, defaultMime);

};

setTimeout(function(){ ajax.send();}, 0); // allows setting custom ajax headers using the return:

return ajax;

} // end if valid url?

} // end if url?

//go ahead and download dataURLs right away

if(/^data\:[\w+\-]+\/[\w+\-]+[,;]/.test(payload)){
if(payload.length > (1024*1024*1.999) && myBlob !== toString ){
payload=dataUrlToBlob(payload);

mimeType=payload.type || defaultMime;

}else{
return navigator.msSaveBlob ? // IE10 can’t do a[download], only Blobs:

navigator.msSaveBlob(dataUrlToBlob(payload), fileName) :

saver(payload) ; // everyone else can save dataURLs un-processed

}

}//end if dataURL passed?

blob = payload instanceof myBlob ?

payload :

new myBlob([payload], {type: mimeType}) ;

function dataUrlToBlob(strUrl) {
var parts= strUrl.split(/[:;,]/),

type= parts[1],

decoder= parts[2] == “base64” ? atob : decodeURIComponent,

binData= decoder( parts.pop() ),

mx= binData.length,

i= 0,

uiArr= new Uint8Array(mx);

for(i;i

return new myBlob([uiArr], {type: type});

}

function saver(url, winMode){
if (‘download’ in anchor) { //html5 A[download]

anchor.href = url;

anchor.setAttribute(“download”, fileName);

anchor.className = “download-js-link”;

anchor.innerHTML = “downloading…”;

anchor.style.display = “none”;

document.body.appendChild(anchor);

setTimeout(function() {
anchor.click();

document.body.removeChild(anchor);

if(winMode===true){setTimeout(function(){ self.URL.revokeObjectURL(anchor.href);}, 250 );}

}, 66);

return true;

}

// handle non-a[download] safari as best we can:

if(/(Version)\/(\d+)\.(\d+)(?:\.(\d+))?.*Safari\//.test(navigator.userAgent)) {
url=url.replace(/^data:([\w\/\-\+]+)/, defaultMime);

if(!window.open(url)){ // popup blocked, offer direct download:

if(confirm(“Displaying New Document\n\nUse Save As… to download, then click back to return to this page.”)){ location.href=url; }

}

return true;

}

//do iframe dataURL download (old ch+FF):

var f = document.createElement(“iframe”);

document.body.appendChild(f);

if(!winMode){ // force a mime that will download:

url=”data:”+url.replace(/^data:([\w\/\-\+]+)/, defaultMime);

}

f.src=url;

setTimeout(function(){ document.body.removeChild(f); }, 333);

}//end saver

if (navigator.msSaveBlob) { // IE10+ : (has Blob, but not a[download] or URL)

return navigator.msSaveBlob(blob, fileName);

}

if(self.URL){ // simple fast and modern way using Blob and URL:

saver(self.URL.createObjectURL(blob), true);

}else{
// handle non-Blob()+non-URL browsers:

if(typeof blob === “string” || blob.constructor===toString ){
try{
return saver( “data:” + mimeType + “;base64,” + self.btoa(blob) );

}catch(y){
return saver( “data:” + mimeType + “,” + encodeURIComponent(blob) );

}

}

// Blob but not URL support:

reader=new FileReader();

reader.οnlοad=function(e){
saver(this.result);

};

reader.readAsDataURL(blob);

}

return true;

}; /* end download() */

}));

用法:

download(fileUrl,name,”video/mp4″);

实现从oss(阿里云)服务器批量下载文件

一、OSS上同一路径下文件批量下载
假设OSS上Bucket中有四个文件:fun/like/001.avi、fun/like/002.avi、fun/like/003.jpg、fun/like/004.mp3,批量下载四个文件,并将四个文件存储本地路径:“D:/fun/like/”下,即:D:/fun/like/(001.avi、002.avi、003.jpg、004.mp3)。

/**
* OSS文件批量下载
*
* @param localPath 本地存储路径
* @param ossPath 文件在OSS上的路径
*/
public static void imageBatchDownload(String localPath, String ossPath) {
// endpoint以杭州为例,其它region请按实际情况填写
String endpoint = “oss-cn-shanghai.aliyuncs.com”;
// 云账号AccessKey有所有API访问权限,建议遵循阿里云安全*佳实践,创建并使用RAM子账号进行API访问或日常运维,请登录 https://ram.console.aliyun.com 创建
String accessKeyId = “<yourAccessKeyId>”;
String accessKeySecret = “<yourAccessKeySecret>”;
String bucketName = “<yourBucketName>”;

// 创建OSSClient实例
OSSClient ossClient = new OSSClient(endpoint, accessKeyId, accessKeySecret);

// 构造ListObjectsRequest请求
ListObjectsRequest listObjectsRequest = new ListObjectsRequest(bucketName);
//Delimiter 设置为 “/” 时,罗列该文件夹下的文件
listObjectsRequest.setDelimiter(“/”);
//Prefix 设为某个文件夹名,罗列以此 Prefix 开头的文件
listObjectsRequest.setPrefix(“fun/like/”);

ObjectListing listing = ossClient.listObjects(listObjectsRequest);

// 遍历所有Object:目录下的文件
for (OSSObjectSummary objectSummary : listing.getObjectSummaries()) {
//key:fun/like/001.avi等,即:Bucket中存储文件的路径
String key = objectSummary.getKey();
//判断文件所在本地路径是否存在,若无,新建目录
File file = new File(localPath + key);
File fileParent = file.getParentFile();
if (!fileParent.exists()) {
fileParent.mkdirs();
}
//下载object到文件
ossClient.getObject(new GetObjectRequest(bucketName, key), file);
}
System.out.println(“下载完成”);
// 关闭client
ossClient.shutdown();
}

二、OSS上不同路径下文件批量下载
若批量下载文件不在同一路径下,假设OSS上Bucket中有四个文件:fun/like/001.avi、fun/hate/002.avi、可进行如下操作:

// 创建OSSClient实例
OSSClient ossClient = new OSSClient(endpoint, accessKeyId, accessKeySecret);
// 构造ListObjectsRequest请求
ListObjectsRequest listObjectsRequest = new ListObjectsRequest(bucketName);
//Delimiter 设置为 “/” 时,罗列该文件夹下的文件
listObjectsRequest.setDelimiter(“/”);
//Prefix 设为某个文件夹名,罗列以此 Prefix 开头的文件
listObjectsRequest.setPrefix(“fun/”);
ObjectListing listing = ossClient.listObjects(listObjectsRequest);
//遍历CommonPrefix:目录下的所有子文件夹
for (String commonPrefix : listing.getCommonPrefixes()) {
//commonPrefix: fun/like/、fun/hate/
System.out.println(commonPrefix);
//按照上述代码进行批量下载
}

三、命令行方式管理OSS数据工具 —— ossutil
阿里OSS工具说明:使用说明

%title插图%num

根据windows系统版本下载ossutil安装包,解压使用,双击ossutil.bat跳出命令行窗口
ossutil64.exe config -e oss-cn-shanghai.aliyuncs.com -i <accessKeyId> -k <accessKeySecret>
本地批量上传至OSS:
ossutil64.exe cp <本地目录> oss://<bucketName>[/<上传路径>/] -r
OSS批量下载至本地:
ossutil64.exe cp oss://<bucketName>/[<上传路径>/] <本地目录> -r

云服务的三种方式

云服务的三种方式:IaaS(Infrastructure is as Service)基础设施服务,提供云端IT物理服务(包括工作站,网络,安全防护等打包服务)/ PaaS(Plateform is as Service)开发平台服务,有时也称中间件(包括web 服务器,数据库,运行环境,编译测试环境等开发服务)/ SaaS(Software is as Service)软件服务(即面向普通客户的服务,如搜索,视频,在线编辑等服务)。其层次由低到高依次为:IaaS ==> Paas ==> SaaS。

RecyclerView的滚动事件分析

列表的滚动一般分为两种:

  1. 手指按下 -> 手指拖拽列表移动 -> 手指停止拖拽 -> 抬起手指
  2. 手指按下 -> 手指快速拖拽后抬起手指 -> 列表继续滚动 -> 停止滚动

从上面可以看出,滚动状态分为:

|--静止
|--滚动
    |--被迫拖拽移动
    |--自己滚动

上面的过程的状态变化如下:

  1. 静止 -> 被迫拖拽移动 -> 静止
  2. 静止 -> 被迫拖拽移动 -> 自己滚动 -> 静止

<!–more–>

监听RecyclerView的滚动

好了,我们分析完滚动的过程,再看看如何监听RecyclerView的滚动.查看源码是*好的方法.

看源码

查看RecyclerView的源码,我们可以看到以下代码:

/**
 * Set a listener that will be notified of any changes in scroll state or position.
 * @param listener Listener to set or null to clear
 * @deprecated Use {@link #addOnScrollListener(OnScrollListener)} and
 *             {@link #removeOnScrollListener(OnScrollListener)}
 */
@Deprecated
public void setOnScrollListener(OnScrollListener listener) {
    mScrollListener = listener;
}

/**
 * Add a listener that will be notified of any changes in scroll state or position.
 * <p>Components that add a listener should take care to remove it when finished.
 * Other components that take ownership of a view may call {@link #clearOnScrollListeners()}
 * to remove all attached listeners.</p>
 * @param listener listener to set or null to clear
 */
public void addOnScrollListener(OnScrollListener listener) {
    if (mScrollListeners == null) {
        mScrollListeners = new ArrayList<>();
    }
    mScrollListeners.add(listener);
}

也就是说有两种方式可以监听滚动事件:

  1. 其中 setOnScrollListener 已经过时( 设置的监听器源码如下:
    public abstract static class OnScrollListener {
        /**
         * Callback method to be invoked when RecyclerView's scroll state changes.
         * @param recyclerView The RecyclerView whose scroll state has changed.
         * @param newState     The updated scroll state. One of {@link #SCROLL_STATE_IDLE},
         *                     {@link #SCROLL_STATE_DRAGGING} or {@link #SCROLL_STATE_SETTLING}.
         */
        public void onScrollStateChanged(RecyclerView recyclerView, int newState){}
    
        /**
         * Callback method to be invoked when the RecyclerView has been scrolled. This will be
         * called after the scroll has completed.
         * <p>
         * This callback will also be called if visible item range changes after a layout
         * calculation. In that case, dx and dy will be 0.
         *
         * @param recyclerView The RecyclerView which scrolled.
         * @param dx The amount of horizontal scroll.
         * @param dy The amount of vertical scroll.
         */
        public void onScrolled(RecyclerView recyclerView, int dx, int dy){}
    }

    在滚动过程中,此监听器会回调两个方法.

    onScrollStateChanged : 滚动状态变化时回调
    onScrolled : 滚动时回调

    这两者的区别在于: 状态与过程

    举例子

    注 : 以下源码可在*后的地址中找到.

    demoRv = (RecyclerView) findViewById(R.id.demo_rv);
    layoutManager = new LinearLayoutManager(this);
    demoRv.setLayoutManager(layoutManager);
    demoRv.addItemDecoration(new DividerItemDecoration(this, DividerItemDecoration.VERTICAL_LIST));
    
    bookAdapter = new BookAdapter();
    bookAdapter.fillList(MockService.getBookList());
    demoRv.setAdapter(bookAdapter);
    
    demoRv.addOnScrollListener(new RecyclerView.OnScrollListener() {
        @Override
        public void onScrollStateChanged(RecyclerView recyclerView, int newState) {
            super.onScrollStateChanged(recyclerView, newState);
            Log.i(TAG, "-----------onScrollStateChanged-----------");
            Log.i(TAG, "newState: " + newState);
        }
    
        @Override
        public void onScrolled(RecyclerView recyclerView, int dx, int dy) {
            super.onScrolled(recyclerView, dx, dy);
            Log.i(TAG, "-----------onScrolled-----------");
            Log.i(TAG, "dx: " + dx);
            Log.i(TAG, "dy: " + dy);
            Log.i(TAG, "CHECK_SCROLL_UP: " + recyclerView.canScrollVertically(TAG_CHECK_SCROLL_UP));
            Log.i(TAG, "CHECK_SCROLL_DOWN: " + recyclerView.canScrollVertically(TAG_CHECK_SCROLL_DOWN));
        }
    });

    以上代码中输出了主要个几个信息:

    1. newState : 目前的状态
    2. dx : 水平滚动距离
    3. dy : 垂直滚动距离

    onScrollStateChanged 方法

  2. recyclerView : 当前在滚动的RecyclerView
  3. newState : 当前滚动状态.

其中newState有三种值:

//停止滚动
public static final int SCROLL_STATE_IDLE = 0;

//正在被外部拖拽,一般为用户正在用手指滚动
public static final int SCROLL_STATE_DRAGGING = 1;

//自动滚动开始
public static final int SCROLL_STATE_SETTLING = 2;

onScrolled 方法

  • recyclerView : 当前滚动的view
  • dx : 水平滚动距离
  • dy : 垂直滚动距离

真机实践

运行代码

运行以上代码,然后按照上面的滚动过程分别进行两种滚动.

*种方式缓慢滚动结果如下:

I/MainActivity: -----------onScrollStateChanged-----------
I/MainActivity: newState: 1
I/MainActivity: -----------onScrolled-----------
I/MainActivity: dx: 0
I/MainActivity: dy: -6
I/MainActivity: CHECK_SCROLL_UP: true
I/MainActivity: CHECK_SCROLL_DOWN: true
------------------------n个onScrolled--------------------
I/MainActivity: -----------onScrolled-----------
I/MainActivity: dx: 0
I/MainActivity: dy: -2
I/MainActivity: CHECK_SCROLL_UP: true
I/MainActivity: CHECK_SCROLL_DOWN: false
I/MainActivity: -----------onScrollStateChanged-----------
I/MainActivity: newState: 0

第二种快速滚动结果如下:

I/MainActivity: -----------onScrollStateChanged-----------
I/MainActivity: newState: 1
I/MainActivity: -----------onScrolled-----------
I/MainActivity: dx: 0
I/MainActivity: dy: 59
I/MainActivity: CHECK_SCROLL_UP: true
I/MainActivity: CHECK_SCROLL_DOWN: true
--------------------------n个onScrolled-------------------
I/MainActivity: -----------onScrolled-----------
I/MainActivity: dx: 0
I/MainActivity: dy: 54
I/MainActivity: CHECK_SCROLL_UP: true
I/MainActivity: CHECK_SCROLL_DOWN: true
I/MainActivity: -----------onScrollStateChanged-----------
I/MainActivity: newState: 2
I/MainActivity: -----------onScrolled-----------
I/MainActivity: dx: 0
I/MainActivity: dy: 56
I/MainActivity: CHECK_SCROLL_UP: true
I/MainActivity: CHECK_SCROLL_DOWN: true
--------------------------n个onScrolled-------------------
I/MainActivity: -----------onScrolled-----------
I/MainActivity: dx: 0
I/MainActivity: dy: 14
I/MainActivity: CHECK_SCROLL_UP: true
I/MainActivity: CHECK_SCROLL_DOWN: true
I/MainActivity: -----------onScrolled-----------
I/MainActivity: dx: 0
I/MainActivity: dy: 1
I/MainActivity: CHECK_SCROLL_UP: true
I/MainActivity: CHECK_SCROLL_DOWN: true
I/MainActivity: -----------onScrollStateChanged-----------
I/MainActivity: newState: 0

分析结果

且在滚动过程中发现:

1.滚动方向

dy > 0 时为向上滚动
dy < 0 时为向下滚动

2.回调过程

缓慢拖拽回调过程:

1. newState = RecyclerView.SCROLL_STATE_DRAGGING;
2. dy 多次改变
3. newState = RecyclerView.SCROLL_STATE_IDLE

快速滚动回调过程:

1. newState = RecyclerView.SCROLL_STATE_DRAGGING;
2. dy 多次改变
3. newState = RecyclerView.SCROLL_STATE_SETTLING;
4. dy 多次改变
5. newState = RecyclerView.SCROLL_STATE_IDLE;

3.顶端与底部

以上信息中还打印了

RecyclerView.canScrollVertically(-1)的值表示是否滚动到顶部

封装

基于以上,我们可以封装一个可以回调滚动状态和方向的RecyclerView.

先建立事件监听的接口public interface OnScrollCallback { void onStateChanged(ScrollRecycler recycler, int state); void onScrollUp(ScrollRecycler recycler, int dy); void onScrollDown(ScrollRecycler recycler, int dy); }

再写一个类RecyclerView,在类中添加以下方法:

public void setOnScrollCallback(final OnScrollCallback callback) {
    if (callback == null) {
        return;
    }
    addOnScrollListener(new OnScrollListener() {
        @Override
        public void onScrollStateChanged(RecyclerView recyclerView, int newState) {
            super.onScrollStateChanged(recyclerView, newState);
            callback.onStateChanged(ScrollRecycler.this, newState);
        }

        @Override
        public void onScrolled(RecyclerView recyclerView, int dx, int dy) {
            super.onScrolled(recyclerView, dx, dy);
            if (dy > 0) {
                callback.onScrollDown(ScrollRecycler.this, dy);
            } else {
                callback.onScrollUp(ScrollRecycler.this, dy);
            }
        }
    });
}

 

RecycleViewScrollHelper–RecyclerView滑动事件检测的辅助类

概述

这是一个关于RecycleView滑动事件的辅助类,该辅助类可以检测RecycleView滑动到顶部或者底部的状态.
可用于实现RecycleView加载更多或者刷新(虽然刷新可以直接用SwipeRefreshLayout).也可用于某些滑动相关的需求,如FloatingActionButton的隐藏与显示之类的.


关于RecycleView的滑动监听

RecycleView本身已经提供了滑动的监听接口,OnScrollListener,这个接口包含了以下的方法.

//当recycleView的滑动状态改变时回调
public void onScrollStateChanged(RecyclerView recyclerView, int newState){}
//当RecycleView滑动之后被回调
public void onScrolled(RecyclerView recyclerView,int dx, int dy){}

由以上状态我们可以根据不同的状态去判断RecycleView当前的位置或者是滚动状态.


关于滑动位置的监听

我们需要确定的是RecycleView是否已经滑动到底部或者是顶部.
由以上提及的状态我们可以确定,当前RecycleView滑动到顶部或者底部时,其滚动状态都是静止的,这时状态应该是SCROLL_STATE_IDLE.
确定了状态,下面需要确定的就是当前的item是否为顶部或者是底部的item?
关于这个问题,其实RecycleView已经有相关的方法可以查询到了(严格来说应该是RecycleViewLayoutManager),网上已经有很多相关的博客说明.这里也是参考了一下一些博客,这里给出一个地址,可以了解一下,下面也会提及如何检测,如果觉得链接内容太多可以跳过.
参考链接

特别说明,为了避免混乱
1.这里使用itemView表示adapter里每一个position对应的view;
2.position都是指adapter中的数据的位置
3.使用childView表示RecycleView缓存复用的子view


检测边界的itemView

关于itemView的位置确定,可以通过LinearLayoutManager获取到当前显示的view对应adapter中的position.

LinearLayoutManager linearManager = (LinearLayoutManager) layoutManager;
//查找*后一个可见的item的position
int lastItemPosition = linearManager.findLastVisibleItemPosition();
//查找*个可见的item的position
int firstItemPosition =linearManager.findFirstVisibleItemPosition();

以上是简单的顶部/底部判断方式.


简单判断方式的缺点

以上已经介绍了如何判断RecycleView滑动到顶部和底部的方式.但这个判断方式是有缺陷的.问题在于RecycleView的可见itemView的查找上.

itemView的可见问题

RecycleView中的itemView是可大可小的,这个取决于我们的实际使用场景及业务.当itemView的内容比较多时,将会占据相当一部分RecycleView的界面.所以我们往往存在这种情况:
某些itemView会在滑动过程中只显示一部分或者一半
但是这种情况下,该itemView还是属于一个可见(visible).

任何时候一个itemView只要有任何一部分显示在RecycleView上时,该itemView都是可见的

回到我们之前查找边界itemView的方法中,查找边界用的方法是:

linearLayoutManager.findFirstVisibleItemPosition(

示例:当设置容差值为item的一半高度时,则在顶部或者底部item超过一半滑出界面时即可以触发回调事件.


完整地检测满屏并滑动到底部(或者顶部)

将以上两个检测满屏与检测滑动到底部的方法组合起来即可.
*后附上更加具体的检测方式:

  • 可以设置先检测滑动到底部还是顶部
  • 可以设置在先检测到某一种情况时是否还继续检测另一种情况(栽些情况下可能需要同时检测是否滑动到顶部及底部)
  • 可以设置是否检测满屏的情况(非满屏情况下不触发滑动事件)
  • 可以设置检测滑动到顶部/底部的容差值(即扩大检测范围)

拦截recyclerview 的item 的点击事件

拦截recyclerview 的item 的点击事件

recyclerview.addOnItemTouchListener(new RecyclerItemClickListener(getActivity(),recyclerview, new RecyclerItemClickListener.OnItemClickListener() {
            @Override
            public void onItemClick(View view, int position) {
                Log.e("hello","hello");
            }
        }));


/**
 * 拦截Recycler的item的点击事件
 */
public static class RecyclerItemClickListener implements RecyclerView.OnItemTouchListener {
    private OnItemClickListener mListener;


    public interface OnItemClickListener {
        public void onItemClick(View view, int position);
        public void onLongItemClick(View view, int position);
    }

    GestureDetector mGestureDetector;

    public RecyclerItemClickListener(Context context, final RecyclerView recyclerView, OnItemClickListener listener) {
        mListener = listener;
        mGestureDetector = new GestureDetector(context, new GestureDetector.SimpleOnGestureListener() {
            @Override
            public boolean onSingleTapUp(MotionEvent e) {
                return true;
            }

            @Override
            public void onLongPress(MotionEvent e) {
                View child = recyclerView.findChildViewUnder(e.getX(), e.getY());
                if (child != null && mListener != null) {
                    mListener.onLongItemClick(child, recyclerView.getChildAdapterPosition(child));
                }
            }
        });
    }

    @Override public boolean onInterceptTouchEvent(RecyclerView view, MotionEvent e) {
        View childView = view.findChildViewUnder(e.getX(), e.getY());
        if (childView != null && mListener != null && mGestureDetector.onTouchEvent(e)) {
            mListener.onItemClick(childView, view.getChildAdapterPosition(childView));
            return true;
        }
        return false;
    }

    @Override public void onTouchEvent(RecyclerView view, MotionEvent motionEvent) { }

    @Override
    public void onRequestDisallowInterceptTouchEvent (boolean disallowIntercept){}
}

 

RecyclerView 缓存机制详解

一 前言

RecyclerView据官方的介绍,该控件用于在有限的窗口中展示大量数据集,其实这样功能的控件我们并不陌生,例如:ListView、GridView。RecyclerView可以用来代替传统的ListView,GridView,更加强大和灵活。RecyclerView的使用网上有非常多案例,这里就不多说了,我们今天主要来看看RecyclerView 的缓存机制。

二 缓存机制Recycler详解

Recycler是RecyclerView的一个内部类。我们来看一下它的主要的成员变量。

  1. mChangedScrap 表示数据已经改变的ewHolder列表
  2. mAttachedScrap 未与RecyclerView分离的ViewHolder列表
  3. mCachedViews ViewHolder缓存列表,其大小由mViewCacheMax决定,默认DEFAULT_CACHE_SIZE为2,可动态设置。
  4. mViewCacheExtension 开发者可自定义的一层缓存,是虚拟类ViewCacheExtension的一个实例,开发者可实现方法getViewForPositionAndType(Recycler recycler, int position, int type)来实现自己的缓存。
  5. mRecyclerPool ViewHolder缓存池,在有限的mCachedViews中如果存不下ViewHolder时,就会把ViewHolder存入RecyclerViewPool中。

我们来看一张RecyclerView 缓存机制的流程图,如下图
这里写图片描述

贴上源码,如下。我们根据流程图和源码来分析RecyclerView的缓存机制。

        public View getViewForPosition(int position) {
            return getViewForPosition(position, false);
        }

        View getViewForPosition(int position, boolean dryRun) {
            if (position < 0 || position >= mState.getItemCount()) {
                throw new IndexOutOfBoundsException("Invalid item position " + position
                        + "(" + position + "). Item count:" + mState.getItemCount());
            }
            boolean fromScrap = false;
            ViewHolder holder = null;
            // 0) If there is a changed scrap, try to find from there
            if (mState.isPreLayout()) {
                holder = getChangedScrapViewForPosition(position);
                fromScrap = holder != null;
            }
            // 1) Find from scrap by position
            if (holder == null) {
                holder = getScrapViewForPosition(position, INVALID_TYPE, dryRun);
                if (holder != null) {
                    if (!validateViewHolderForOffsetPosition(holder)) {
                        // recycle this scrap
                        if (!dryRun) {
                            // we would like to recycle this but need to make sure it is not used by
                            // animation logic etc.
                            holder.addFlags(ViewHolder.FLAG_INVALID);
                            if (holder.isScrap()) {
                                removeDetachedView(holder.itemView, false);
                                holder.unScrap();
                            } else if (holder.wasReturnedFromScrap()) {
                                holder.clearReturnedFromScrapFlag();
                            }
                            recycleViewHolderInternal(holder);
                        }
                        holder = null;
                    } else {
                        fromScrap = true;
                    }
                }
            }
            if (holder == null) {
                final int offsetPosition = mAdapterHelper.findPositionOffset(position);
                if (offsetPosition < 0 || offsetPosition >= mAdapter.getItemCount()) {
                    throw new IndexOutOfBoundsException("Inconsistency detected. Invalid item "
                            + "position " + position + "(offset:" + offsetPosition + ")."
                            + "state:" + mState.getItemCount());
                }

                final int type = mAdapter.getItemViewType(offsetPosition);
                // 2) Find from scrap via stable ids, if exists
                if (mAdapter.hasStableIds()) {
                    holder = getScrapViewForId(mAdapter.getItemId(offsetPosition), type, dryRun);
                    if (holder != null) {
                        // update position
                        holder.mPosition = offsetPosition;
                        fromScrap = true;
                    }
                }
                if (holder == null && mViewCacheExtension != null) {
                    // We are NOT sending the offsetPosition because LayoutManager does not
                    // know it.
                    final View view = mViewCacheExtension
                            .getViewForPositionAndType(this, position, type);
                    if (view != null) {
                        holder = getChildViewHolder(view);
                        if (holder == null) {
                            throw new IllegalArgumentException("getViewForPositionAndType returned"
                                    + " a view which does not have a ViewHolder");
                        } else if (holder.shouldIgnore()) {
                            throw new IllegalArgumentException("getViewForPositionAndType returned"
                                    + " a view that is ignored. You must call stopIgnoring before"
                                    + " returning this view.");
                        }
                    }
                }
                if (holder == null) { // fallback to recycler
                    // try recycler.
                    // Head to the shared pool.
                    if (DEBUG) {
                        Log.d(TAG, "getViewForPosition(" + position + ") fetching from shared "
                                + "pool");
                    }
                    holder = getRecycledViewPool().getRecycledView(type);
                    if (holder != null) {
                        holder.resetInternal();
                        if (FORCE_INVALIDATE_DISPLAY_LIST) {
                            invalidateDisplayListInt(holder);
                        }
                    }
                }
                if (holder == null) {
                    holder = mAdapter.createViewHolder(RecyclerView.this, type);
                    if (DEBUG) {
                        Log.d(TAG, "getViewForPosition created new ViewHolder");
                    }
                }
            }

            // This is very ugly but the only place we can grab this information
            // before the View is rebound and returned to the LayoutManager for post layout ops.
            // We don't need this in pre-layout since the VH is not updated by the LM.
            if (fromScrap && !mState.isPreLayout() && holder
                    .hasAnyOfTheFlags(ViewHolder.FLAG_BOUNCED_FROM_HIDDEN_LIST)) {
                holder.setFlags(0, ViewHolder.FLAG_BOUNCED_FROM_HIDDEN_LIST);
                if (mState.mRunSimpleAnimations) {
                    int changeFlags = ItemAnimator
                            .buildAdapterChangeFlagsForAnimations(holder);
                    changeFlags |= ItemAnimator.FLAG_APPEARED_IN_PRE_LAYOUT;
                    final ItemHolderInfo info = mItemAnimator.recordPreLayoutInformation(mState,
                            holder, changeFlags, holder.getUnmodifiedPayloads());
                    recordAnimationInfoIfBouncedHiddenView(holder, info);
                }
            }

            boolean bound = false;
            if (mState.isPreLayout() && holder.isBound()) {
                // do not update unless we absolutely have to.
                holder.mPreLayoutPosition = position;
            } else if (!holder.isBound() || holder.needsUpdate() || holder.isInvalid()) {
                if (DEBUG && holder.isRemoved()) {
                    throw new IllegalStateException("Removed holder should be bound and it should"
                            + " come here only in pre-layout. Holder: " + holder);
                }
                final int offsetPosition = mAdapterHelper.findPositionOffset(position);
                holder.mOwnerRecyclerView = RecyclerView.this;
                mAdapter.bindViewHolder(holder, offsetPosition);
                attachAccessibilityDelegate(holder.itemView);
                bound = true;
                if (mState.isPreLayout()) {
                    holder.mPreLayoutPosition = position;
                }
            }

            final ViewGroup.LayoutParams lp = holder.itemView.getLayoutParams();
            final LayoutParams rvLayoutParams;
            if (lp == null) {
                rvLayoutParams = (LayoutParams) generateDefaultLayoutParams();
                holder.itemView.setLayoutParams(rvLayoutParams);
            } else if (!checkLayoutParams(lp)) {
                rvLayoutParams = (LayoutParams) generateLayoutParams(lp);
                holder.itemView.setLayoutParams(rvLayoutParams);
            } else {
                rvLayoutParams = (LayoutParams) lp;
            }
            rvLayoutParams.mViewHolder = holder;
            rvLayoutParams.mPendingInvalidate = fromScrap && bound;
            return holder.itemView;
        }

 

主流程 1
我们来看主流程源码的第14行

  holder = getChangedScrapViewForPosition(position);

 

我们通过position匹配 mChangedScrap 获取holder缓存。
getChangedScrapViewForPosition(position)方法内部通过2种方法获取holder缓存。*种通过mChangedScrap匹配 position获取holder缓存。第二种通过mChangedScrap匹配id获取holder缓存。源码如下。

        ViewHolder getChangedScrapViewForPosition(int position) {
            // If pre-layout, check the changed scrap for an exact match.
            final int changedScrapSize;
            if (mChangedScrap == null || (changedScrapSize = mChangedScrap.size()) == 0) {
                return null;
            }
            // *种 通过 position来查找
            for (int i = 0; i < changedScrapSize; i++) {
                final ViewHolder holder = mChangedScrap.get(i);
                if (!holder.wasReturnedFromScrap() && holder.getLayoutPosition() == position) {
                    holder.addFlags(ViewHolder.FLAG_RETURNED_FROM_SCRAP);
                    return holder;
                }
            }
            //第二种 通过 id来查找
            if (mAdapter.hasStableIds()) {
                final int offsetPosition = mAdapterHelper.findPositionOffset(position);
                if (offsetPosition > 0 && offsetPosition < mAdapter.getItemCount()) {
                    final long id = mAdapter.getItemId(offsetPosition);
                    for (int i = 0; i < changedScrapSize; i++) {
                        final ViewHolder holder = mChangedScrap.get(i);
                        if (!holder.wasReturnedFromScrap() && holder.getItemId() == id) {
                            holder.addFlags(ViewHolder.FLAG_RETURNED_FROM_SCRAP);
                            return holder;
                        }
                    }
                }
            }
            return null;
        }

 

主流程 2
我们看一下主流程第19行代码。

holder = getScrapViewForPosition(position, INVALID_TYPE, dryRun);

 

通过position查找废弃的holder,我们来看一下getScrapViewForPosition方法内部实现,主要通过3种方法获取holder缓存。
*种从mAttachedScrap中通过匹配position获取holder缓存。
第二种通过ChildHelper找到隐藏但是没有被移除的View,通过getChildViewHolderInt(view)方法获取holder缓存。
第三种从mCachedViews中通过匹配position获取holder缓存。
getScrapViewForPosition源码如下

  ViewHolder getScrapViewForPosition(int position, int type, boolean dryRun) {
            final int scrapCount = mAttachedScrap.size();

            // *种从mAttachedScrap中通过匹配position获取holder缓存。
            for (int i = 0; i < scrapCount; i++) {
                final ViewHolder holder = mAttachedScrap.get(i);
                if (!holder.wasReturnedFromScrap() && holder.getLayoutPosition() == position
                        && !holder.isInvalid() && (mState.mInPreLayout || !holder.isRemoved())) {
                    if (type != INVALID_TYPE && holder.getItemViewType() != type) {
                        Log.e(TAG, "Scrap view for position " + position + " isn't dirty but has" +
                                " wrong view type! (found " + holder.getItemViewType() +
                                " but expected " + type + ")");
                        break;
                    }
                    holder.addFlags(ViewHolder.FLAG_RETURNED_FROM_SCRAP);
                    return holder;
                }
            }
            //通过ChildHelper找到隐藏但是没有被移除的View,通过getChildViewHolderInt(view)方法获取holder缓存。
            if (!dryRun) {
                View view = mChildHelper.findHiddenNonRemovedView(position, type);
                if (view != null) {
                    // This View is good to be used. We just need to unhide, detach and move to the
                    // scrap list.
                    final ViewHolder vh = getChildViewHolderInt(view);
                    mChildHelper.unhide(view);
                    int layoutIndex = mChildHelper.indexOfChild(view);
                    if (layoutIndex == RecyclerView.NO_POSITION) {
                        throw new IllegalStateException("layout index should not be -1 after "
                                + "unhiding a view:" + vh);
                    }
                    mChildHelper.detachViewFromParent(layoutIndex);
                    scrapView(view);
                    vh.addFlags(ViewHolder.FLAG_RETURNED_FROM_SCRAP
                            | ViewHolder.FLAG_BOUNCED_FROM_HIDDEN_LIST);
                    return vh;
                }
            }

            // Search in our first-level recycled view cache.
            //第三种从mCachedViews中通过匹配position获取holder缓存。
            final int cacheSize = mCachedViews.size();
            for (int i = 0; i < cacheSize; i++) {
                final ViewHolder holder = mCachedViews.get(i);
                // invalid view holders may be in cache if adapter has stable ids as they can be
                // retrieved via getScrapViewForId
                if (!holder.isInvalid() && holder.getLayoutPosition() == position) {
                    if (!dryRun) {
                        mCachedViews.remove(i);
                    }
                    if (DEBUG) {
                        Log.d(TAG, "getScrapViewForPosition(" + position + ", " + type +
                                ") found match in cache: " + holder);
                    }
                    return holder;
                }
            }
            return null;
        }

 

主流程 3
我们看一下主流程第52行代码。

 holder = getScrapViewForId(mAdapter.getItemId(offsetPosition), type, dryRun);

 

通过id获取holder缓存,getScrapViewForId方法内部主要通过2种方法获取holder缓存。
*种从mAttachedScrap中通过匹配id获取holder缓存。
第二种从mCachedViews中通过匹配id获取holder缓存。
getScrapViewForId方法源码如下。

        ViewHolder getScrapViewForId(long id, int type, boolean dryRun) {
            //*种从mAttachedScrap中通过匹配id获取holder缓存。
            // Look in our attached views first
            final int count = mAttachedScrap.size();
            for (int i = count - 1; i >= 0; i--) {
                final ViewHolder holder = mAttachedScrap.get(i);
                if (holder.getItemId() == id && !holder.wasReturnedFromScrap()) {
                    if (type == holder.getItemViewType()) {
                        holder.addFlags(ViewHolder.FLAG_RETURNED_FROM_SCRAP);
                        if (holder.isRemoved()) {
                            // this might be valid in two cases:
                            // > item is removed but we are in pre-layout pass
                            // >> do nothing. return as is. make sure we don't rebind
                            // > item is removed then added to another position and we are in
                            // post layout.
                            // >> remove removed and invalid flags, add update flag to rebind
                            // because item was invisible to us and we don't know what happened in
                            // between.
                            if (!mState.isPreLayout()) {
                                holder.setFlags(ViewHolder.FLAG_UPDATE, ViewHolder.FLAG_UPDATE |
                                        ViewHolder.FLAG_INVALID | ViewHolder.FLAG_REMOVED);
                            }
                        }
                        return holder;
                    } else if (!dryRun) {
                        // if we are running animations, it is actually better to keep it in scrap
                        // but this would force layout manager to lay it out which would be bad.
                        // Recycle this scrap. Type mismatch.
                        mAttachedScrap.remove(i);
                        removeDetachedView(holder.itemView, false);
                        quickRecycleScrapView(holder.itemView);
                    }
                }
            }
            //第二种从mCachedViews中通过匹配id获取holder缓存。
            // Search the first-level cache
            final int cacheSize = mCachedViews.size();
            for (int i = cacheSize - 1; i >= 0; i--) {
                final ViewHolder holder = mCachedViews.get(i);
                if (holder.getItemId() == id) {
                    if (type == holder.getItemViewType()) {
                        if (!dryRun) {
                            mCachedViews.remove(i);
                        }
                        return holder;
                    } else if (!dryRun) {
                        recycleCachedViewAt(i);
                    }
                }
            }
            return null;
        }

 

主流程 4
我们看一下主流程第62行代码。
通过mViewCacheExtension.getViewForPositionAndType获取view,通过getChildViewHolder(view)获取holder缓存。源码如下

                final View view = mViewCacheExtension
                        .getViewForPositionAndType(this, position, type);
                if (view != null) {
                    holder = getChildViewHolder(view);
                    if (holder == null) {
                        throw new IllegalArgumentException("getViewForPositionAndType returned"
                                + " a view which does not have a ViewHolder");
                    } else if (holder.shouldIgnore()) {
                        throw new IllegalArgumentException("getViewForPositionAndType returned"
                                + " a view that is ignored. You must call stopIgnoring before"
                                + " returning this view.");
                    }
                }

 

主流程 5
我们看一下主流程第83行代码。
holder = getRecycledViewPool().getRecycledView(type);
通过RecyclerView 的ViewHolder缓存池获取holder。
通过holder.resetInternal();方法将holder复位,为后续重新绑定做好准备。

主流程 6
我们看一下主流程第92行代码。
holder = mAdapter.createViewHolder(RecyclerView.this, type);创建新的holder

主流程 7
我们看一下主流程第119行代码。
if (!holder.isBound() || holder.needsUpdate() || holder.isInvalid())
判断是否要重新绑定ViewHolder。

主流程就是这样了。

三 总结

经过上面的分析,我们可以看出RecyclerView 缓存机制(Recycler)大致可以分为5级。
*级 通过mChangedScrap匹配 position或者id获取holder缓存。
第二级 从mAttachedScrap中通过匹配position获取holder缓存,或者通过ChildHelper找到隐藏但是没有被移除的View,通过getChildViewHolderInt(view)方法获取holder缓存,或者
从mCachedViews中通过匹配position获取holder缓存。
第三级 从mAttachedScrap中通过匹配id获取holder缓存,或者
从mCachedViews中通过匹配id获取holder缓存。
第四级 从ViewCacheExtension获取holder缓存。
第五级 通过RecyclerView 的ViewHolder缓存池获取holder。

*后有什么理解不对的地方请大家多多指教。谢谢。