阅读 4767

闲鱼前端 Faas 框架与通信方案|前端搞 Serverless

下期预告

前端早早聊大会目标成为用得上、听得懂、抄得走的技术大会,计划 2020 年办 >= 15 期,由前端早早聊与掘金联合举办,前端早早聊大会行程动态、录播视频/PPT/讲稿资料下载请关注 「前端早早聊」 公众号跟进。

你的支持,是早早聊办下去的唯一动力!

还想听哪方面的分享,直接加 Scott 微信: codingdreamer 提需求吧!


第十三届|前端搞构建专场,8-15 即将直播,9 位讲师(宋小菜|百度|政采云|腾讯|天猫精灵|蚂蚁|淘宝),👉报名地址


本文是前端早早聊的第 45 位讲师,也是第六届 - Serverless 专场,来自闲鱼前端团队的丹侠的分享 - 讲稿简要整理版(完整版含演示请看录播视频和 PPT):

讲师介绍

丹侠:先后在 B2B、蚂蚁金服、闲鱼团队担任前端基础技术建设和业务研发模式的探索。 海潴:目前就职于闲鱼的架构组,参与闲鱼一体化开发模型的设计和实现。

概述

今天给大家分享一下闲鱼如何在现有产品中落地 FaaS,希望我们的实践可以给大家带来借鉴和灵感。今天跟我一同分享的,还有我的同事,海潴。他是我们团队的架构师,FaaS 层的通信架构就是由他设计并实现。在后续的 PPT 中,有关通信相关的技术点,会由他来带给大家。

之前 5 场,大家已经听到了像 Severless、云计算、云平台等跟 FaaS 关系比较紧密的概念。接下来我和海潴讲的内容里,不会涉及太多的基础概念,我们的方案不限定具体某个技术栈或者是工具,也没有要求使用特定的平台及环境。我们会更倾向于解决具体业务问题。主要给大家分享一套设计思路和方法论。讲的是在富交互场景的产品链路里,如何利用 FaaS,给现有的研发模式提供一些便利和想象空间。当然,FaaS 目前在前端领域,产生很多新的研发创新思路的同时,也是有一些不可忽视的问题存在,也是需要大家一起不断思考和实践,并逐渐完善。

闲鱼的端技术和 FaaS 研发体系

首先让大家了解下闲鱼目前的端技术组成。闲鱼给业内印象比较深的,可能是 Flutter 的研发体系。但是闲鱼也有很多基于 Web 的应用场景,这些场景,还是基于前端比较熟悉的技术栈来实现的,比如 React,Vue,小程序。除了端内有不少 Web 的场景,在端外的投放,基本也都是基于前端 H5 技术来实现的。

516-final.003.png
516-final.003.png

大家知道,Flutter 官方提供的编程语言是 Dart。所以闲鱼在 FaaS 体系构建之初,是把 Dart 作为 FaaS 层的编程语言,这样客户端就可以实现一体化的研发体验。这跟前端使用 TS 结合 Node 是一样的道理。在 FaaS 的实践过程中,前端又是跟客户端共享一套 FaaS 技术框架,所以为了让前端同学也可以实现一体化的编程体验,前端在工程上做了一层语法转化,实现了用 TS 编程,构建时转化成 Dart 的工程能力,这样也就间接实现了一体化的研发体验。

所以目前闲鱼 FaaS 研发的技术环境就是:客户端和前端共用同一套 FaaS 框架。 我们的 FaaS 函数,是部署在一个叫盖亚的研发部署平台,这个平台是阿里内部面向函数的研发运维平台,这个对各位同学来说并不是唯一选择,大家可以使用自家公司的,或者自己熟悉的平台来部署 FaaS 函数。

传统的 MVVM 的研发模式

先回顾一下传统的前端研发模式,大部分同学,对 MVVM 应该比较熟悉。像 React、Vue 都是擅长使用这套模式管理前端代码的。

516-final.004.png
516-final.004.png

重端侧的研发过程中,会有三个概念:Model、View、ViewModel,这三个都是在端侧定义并管理的。统一管理的好处是一个工程维护一套业务,研发效率比较高;缺点就是代码管理成本比较高,复杂业务需要借助一些端侧代码框架,比如 Redux,或者像蚂蚁先后推出开发框架 Dvajs、Umijs,都是帮助大家去管理端侧代码。但即使有这些框架的协助,有时对 UI 状态和业务状态的管理也会比较混乱。这跟业务的多变性、开发人员的综合素质以及开发团队对代码管理的严格程度都有关系。

重端侧的研发模式,有个问题就是技术栈的适配和迁移成本较高,比如从 Vue 迁移到 React 或者是小程序。不光要适配 UI 层,还需要依赖于之前的逻辑层抽象的比较好,否则这个过程肯定是比较痛苦的。这也是近几年小程序比较火的一个原因之一,各家 App 都支持了小程序,可以做到一次开发,多端投放的效果。但是并不是所有的场景都是适合用小程序来实现的。

重端侧研发还有一个问题是前端对接口的控制能力不足。服务端会针对业务提供定制的接口,复用度较差。数据格式转化到 Model 的过程中,还需要端侧做一些适配、转化及容错处理。

当然前端也可以通过 BFF 层,Backend For Frontend,顾名思义,服务前端的后端。但这是一层是非常轻量级的服务层,主要做的是数据的重组,字段补全、格式校验、标准化等能力。最终的数据能力还是没有大的改变。只是把原来服务端不擅长的那部分数据处理工作给揽过来了。

基于 FaaS 的研发模式

基于 FaaS 的研发模式,它给前端的研发模式提供了新的思路。还是熟悉的 MVVM 研发模式,只是端侧只剩下了View、把 Model 和 ViewModel 都迁移到了 FaaS 侧。大部分更新UI的工作通过事件通信来实现。

516-final.005.png
516-final.005.png

可以看到这种模式下,接口的服务能力也有一定的变化,也就是 FaaS 函数跟 Severless 的配合,服务端会提供领域级别的服务,这些领域服务一般设计成可复用,不跟具体的业务逻辑耦合。对业务的逻辑处理和数据的编排和重组,都是放在 FaaS 层。这样前端的逻辑也放在了 FaaS,FaaS 的能力和职责是被放大了。前端可做的事情和想象空间也变得更大。

当然 FaaS 本身是一种无状态的服务,在逻辑处理过程中如果需要使用一些状态存储的能力,就要借助于 BaaS 才能完成一些状态保存的能力。

而且业务逻辑和数据编排的能力放在一起,可以让整个业务逻辑不再割裂,可以更好地进行抽象设计,甚至编排。

FaaS 在淘系的应用之一(导购)

这里举例一个淘系最早应用 FaaS 的场景:导购。导购的端侧场景特点是以展示为主,一般用户的行为主要是滚动屏幕浏览、点击商品跳转。所以它主要关注的技术点是:

  • 商品个性化推荐算法
  • 商品属性透出(数据补全)
  • 排序规则
  • 数据的分页获取
  • 数据或者权益的投放排期
516-final.006.png
516-final.006.png

所以基于这样的业务场景,FaaS 侧要做的主要工作是对服务的编排。

  • 字段映射:FaaS 侧的数据模型跟端侧的 View 模块之间的字段映射。
  • 模板管理:对某个业务模块的实例管理
  • 连接器:连接器是一些 if else 逻辑或者是一些具体的行为(比如数据请求)

模板管理和连接器是可视化编排的重要素材

而数据编排,是下游的服务能力,是给 FaaS 层提供数据的数据中台能力,不包含在 FaaS 应用层。所以我们之前进行调研的时候,也是考虑导购的 FaaS 业务模型,是否可以套用到我们闲鱼产品的 FaaS 模型中呢?

接下来我拿闲鱼的回收、寄卖业务来进行例举分析一下。

闲鱼的具体业务分析

以闲鱼的回收寄卖业务为例:这是两条相似的产品链路,他们共用 类目选择、问卷估价 等业务场景,但回收会多出 信用评估 和 代扣签约 这两个业务场景。

516-final.008.png
516-final.008.png

同时,回收寄卖涉及的类目也比较多,包括手机数码、大家电、图书、旧衣等。最后又有不同的服务商对接,也会影响流程页中的渲染和交互方式。而且不同的类目,对应不同的业务方和运营同学,他们的产品策略也会有些差异。所以看似差不多的链路里,包含了很多的差异性因子。如果把这些因子做统一的数据处理和服务编排,可想而知,前端的逻辑会变得非常复杂,并且难以维护。所以现有的 FaaS 框架不足以管理复杂的产品链路。

综合分析下来,是缺少两个关键的组成:

  1. 缺少富交互场景的通信方案。因为复杂交互,用户的行为响应,端侧只剩下 View,无法进行直接处理,是需要频繁跟 FaaS 进行通信才能实现状态的变更。
  2. 缺少一套 FaaS 侧的业务框架来抽象和管理整个业务。

产品交互与 FaaS 的通信模式

所以基于之前的分析和事后的思考,我们设计出一个模型。这个模型里,最关键的两个概念是:业务框架 FaaS Story 和数据处理框架 Nexus,这两个概念都在 FaaS 侧进行抽象和实现,然后通过一个我们命名为 Logic Engine 的模块,跟端侧进行通信。

516-final.010.png
516-final.010.png

可以看到图中的整个数据链路:端侧的 page state,管理了组件的属性和事件配置,这里的 Action 并不是一个 Function,而是对事件函数的一个配置,端侧的 Logic Engine 会统一解析这个配置,并调用统一的事件函数,组装成统一的数据包,向 FaaS 发起请求。

数据到了 FaaS 侧,还是由 FaaS 侧的 Logic Engine 进行数据包解析,路由匹配到对应的函数进行处理,函数基于 FaaS Story 这套业务框架进行设计,最终把处理后的信息再次通过 FaaS 侧的 Logic Engine 打包返回给端侧,端侧的 Logic Engine 进行解析处理,最终响应具体的 Action(比如更新页面状态,或者是发起另一次数据通信,又或者是调用某个容器的 API 等)。

业务模型 FaaS-Stroy

516-final.011.png
516-final.011.png

基于这样的模型,我们把之前回收寄卖业务映射过来。整个业务,我们我们就定义为一个故事(Story),这个故事有多个 Scene 组成。

这里 Scene 的概念并不是一个单页的概念,而是根据业务来进行定义的,一个页面可能会承载多个 Scene,后面也会例举多 Scene 的业务场景。

一个 Scene,主要由数据模型 Model、编排逻辑函数 Convertor 以及渲染逻辑函数 Render 组成。Model 映射原始的接口数据(也就是服务端的领域模型),一个场景函数中,可能会获取多个 Model;Converter 处理业务逻辑并输出跟端侧 page state 一致的结构,它的作用也就是 MVVM 结构中的 ViewModel;Render 运行在端侧,最终渲染页面并挂载事件。我们的 Nexus 框架实现了统一的端侧事件模型,UI 组件的事件函数同样可以使用这个事件模型,也就是之前提到的 Action 配置,这样组件从初始化到交互都可以由 FaaS 控制。

Story 的函数管理方式类似于端侧 APP 的概念,可以全局上对这些 FaaS 函数进行管理和编排,比如提供一套统一的配置,来定义路由,以及处理函数之间的流转关系。

一个多场景的页面

516-final.012.png
516-final.012.png

之前在介绍闲鱼业务的时候也提到了,我们有很多的类目,不同的类目,虽然主链路基本一致,每个类目对应的品类属性差异还是比较大的,这会影响页面的渲染和交互。同时不同类目对应的业务方也不同,产品策略和营销策略都会有差异。比如有些类目的下单是需要上门取件,有些类目的评估流程中是需要拍照鉴定,有些类目在某个节日要做个特殊的活动等等。

516-final.013.png 所以我们从类目的纬度横向分割了 FaaS 函数,不同类目的个性化逻辑在自己的 FaaS 函数中独立管理,相互之间互不干扰。他们的公共部分被抽象到了公共类库,或者一些工具类库。

Nexus 框架

Nexus 是一种一体化应用开发协议,用于解决 UI / 逻辑 分离下,端 / FaaS 跨系统函数调用的问题。在它上面可以长很多的,基于特定业务场景的框架,比如上面介绍的 Story,还有另外一个同事编写的基于 fish-redux-view 结合 Logic Engine 的 Nexus Framework。 516-final.015.png

首先说一下为什么会有这样一个协议:大家可以看上图左边,在端侧长时间的发展过程中,大家都在致力于解决 UI/逻辑 如何更好得分离的问题。不管是最早的 MVC,还是 MVP,以及 MVVM,都想要解决这个问题。但是无论端侧如何解决,严格得执行各种框架,被分离的逻辑都只是那部分存在于端侧的业务逻辑。

我们如果把目光放得更大一些,会发现,端侧的逻辑是分离出去了,但是网关层的呢?实际上大部分端侧请求的接口,不管是下发数据,还是写入数据,都不是直接面向领域层的调用。而是会经过网关再进行一次逻辑处理。最典型的比如,页面数据请求,几乎很少有页面去直接面向领域层的多个接口直接进行数据请求。那么为什么呢?因为领域是面向具体的领域问题进行的设计,而端侧需要是面向UI展示进行设计,这两边的设计天然是后鸿沟的。简单一点说,领域层下发的很多数据,端侧是不要的。而通常一个领域接口无法满足一个页面所需要的所有数据。所以才需要经过网关层进行处理。

所以大家发现没有,不管端侧怎么做分离,总会有一部分逻辑存在于网关层。那么如果网关层把所有领域数据都处理成 VO 给到端侧好不好?当然好了,但是后端的同学就不乐意了。一来这个 UI 不是我写的,我还得跟你沟通你需要点啥,每次 UI 改动还得我跟着改。二来写这些东西我也没啥成长。所以更多的时候,是端侧一部分处理逻辑,网关层再做一部分。

这就带来了一个完整业务中 UI / 逻辑 实际并不分离的问题。同时也会造成前后端在沟通、协作上的诸多问题。基于以上,我们思考的是,那不然就不要后端同学来写网关层了,用 FaaS 让前端的同学上去写,自己要什么自己最清楚,也少了协同和沟通,提升效率,还能公用一部分的代码。这就是一体化编程模型的来源。也就是左边的图所表达的。

现在我们打算让 FaaS 来处理所有的业务逻辑。还有两个问题需要解决:

  1. 事实上,业务如何被驱动,都来自于端侧的事件。那么 FaaS 如何感受到来自事件的驱动
  2. FaaS 的处理结果,最终是要在端侧产生 Effect 才行,而这些 Effect 基本上只能由端侧来执行。

所以我们一定是需要一个通信协议,来让端能够调用到 FaaS 上的逻辑函数,也能让 FaaS 能够调用端侧的函数产生 Effect。

整个协议在数据部分基于 NexusBinderAction 体系,将每一个 Action 映射到一个逻辑 Handler 上。Action 是一种数据信息载体,它内部的信息实际上体现的是调用一个函数所需要的“函数签名”和“函数入参”两类关键信息,某种意义上来说,它也是一种“跨系统的函数调用”协议。

但是它与传统的 RPC 协议之间,有什么区别,或者特点呢?这与端侧 UI 编程的特征有关。在端侧编程中,我们发现,有三类的函数调用是可以被归纳的: 1、调用一个后端函数(即执行一段业务逻辑) 2、调用端侧的公共能力函数 3、修改数据并重新渲染。

这三类函数是端侧编程中被大量重复使用的函数,尤其是第三类。UI = F(state)。这些的背后都隐含着一个操作,就是业务逻辑操作。不管是由某一个动作触发的状态改变,还是网络请求,还是诸如 dialog,页面跳转,这背后都需要一些逻辑代码来进行判断和修改。

基于对端侧编程中常用的函数调用的抽象,我们得到了一个 Action 的有限集合。以及支撑这个协议的库 Logic Engine。这里的“有限”非常重要,如果开发者在每次开发一个页面的时候都需要重复得定义大量的 Action 和 Handler,那么一来会增加开发者的负担,二来会出现很多重复的代码,也不符合软件开发中 DRY 的原则。

这样开发方式实际又回到了原始的 Req -> Do something -> Effect,这条路上。所以整个 Action 体系中包含的种类一定要非常少,但它却可以支持大部分的端侧编程中对逻辑函数调用的需求。也就是现在 Nexus 协议的样子。

516-final.016.png
516-final.016.png

根据上面对 Nexus 协议的抽象,可以很明显得看到,无论运行在什么环境中。有两种 Action 的处理逻辑是大体上不变的:

  1. 对于 remote - req 来说,它的逻辑就是解析出 Action 中与请求相关的 apiname、apiversion 以及 params 部分,然后调用一个外部注入进来的网络请求函数把这个调用发送到 FaaS 上去。可以看到,调用一个远程的 FaaS 函数的过程大体上是不变的,也就是 remote - req,Handler 的逻辑可以内置在 Engine 中的的基础。
  2. 从一个 state change 的 Action 中提取新的 UI state,并提交给 UI 进行更新。

另外两种内置的通用 handler 并不来自于 nexus 协议的抽象,而是脱胎于日常开发。

  1. state-diff。它的逻辑是把一个 json patch 合并到当前的 state 中,产生一个新的 state 并提交给 UI 去更新。因为整个端调用 FaaS 的过程中,FaaS 作为无状态服务本身并不存储状态,那么所有计算所需要的状态信息都会由端发送到 FaaS 上。但是 FaaS 在计算完新的 state 之后,并没有必要全量得返回状态数据,本身端侧就保存有 state的原始版本。那么我们考虑,是不是可以通过 diff 的方式让端侧合成一份新的 state,可以有效得减少远程调用的下行流量。为此,我们根据 RFC6901/6902 中关于 json pointer 和 json patch 的规范,在 dart 上实现了json_patch 库。我们在闲鱼内的“下单页”做了测试,“修改地址”操作将会涉及“地址信息”、“红包”、“运费”、“最终价格”数据,使用 diff 后大约可以减少 50% 的下行流量。
  2. 在一体化开发中,我们发现,经常会需要在端侧顺序得执行多个 action。最简单的一个例子,端侧在进行 FaaS调用的时候通常都会 show 一个 progress 来阻塞用户的后续操作,那么当 FaaS 执行完业务逻辑并返回准备好的 state 数据之后,会通过 engine 让页面进行绘制。但是慢着,大家有没有发现哪里不对劲,这个 progress 还 show 在那里,谁来执行 hide progress 的操作呢?所以这种时候 FaaS 会需要下发一个batch类型的 action,顺序得让端侧的 engine 执行 UI update 和 hide progress。

当然开发者可以基于 batch 做更多复杂得 action 编排。所以本质上 batch 类型的 action 提供给开发者一个顺序编排执行逻辑的能力。避免了多次来回请求的开销,也会让执行逻辑变得更加得清晰。

516-final.017.png
516-final.017.png

上一页我们已经把 nexus 协议想要解决的问题说清楚了,那么作为具体执行协议的 Engine,它所需要提供的功能就非常明显了。首先它必须能够执行一种协议到具体逻辑代码的映射功能,这里面包含了协议的解析、函数的映射、函数的执行。最后我们还需要给它加上执行上下文的管理功能。上图中的红色部分,是 Engine 对外提供的功能,包括允许外部进行函数注册,以及外部可以调用某个 API 来进行函数调用。

绿色的部分为 Engine 内部需要提供的功能,包括协议的解析、目标函数的匹配和执行上下文的管理。函数注册、执行函数、解析消息、函数匹配这四个功能是比较容易想到的。对于执行上下文管理功能。Engine 实际上除了绑定了 Action 这种协议载体之外,并不绑定任何的框架或者端侧环境,也就是说对 Engine 来说,你运行在 android native 还是 flutter 环境,对它都没有影响,它依然可以完成自己对接 Action 协议的使命。这种设计是为了尽可能得给 engine 解绑,也可以释放 Engine 的能力以提供业务方进行上层框架的自定义。

最后也是这里把它单独领出来的一块,橘黄色的部分,“内置的通用函数”。也就是上面所说的 remote-req、state-change、state-diff 和 batch。

代码演示

端侧的逻辑代码

516-final.019.png
516-final.019.png

端侧的 UI 跟 ViewModel 的数据模型映射

516-final.020.png
516-final.020.png

FaaS 侧的逻辑

516-final.021.png
516-final.021.png

研发一体化及热部署

image.png
image.png

问题思考

Q:端侧交互的时延

A:减少通信次数,不涉及业务逻辑的行为,在端侧完成。比如曝光、点击埋点。

Q:端侧通信的时序

A:通过,控制端侧的请求顺序,来保证数据的有效性:阻塞交互(Loading)。关于异步时序的问题,我们内部也有一些讨论,比如可以通过 CAS(Compare and Swap)或者事务的方式来保证通信数据的有效性。** **

Q:会话状态的保存

  1. 借助独立的 BaaS 服务存放需要的数据。需要引入 BaaS 服务,应用成本相对高一些,而且缓存的有效期不太好控制,存储量也比较大。但是稳定性和灵活性较强。
  2. 轻量的数据存放能力可以利用端侧的页面生命周期,我们在 Nexus 的通信协议里可以自定义需要页面生命周期内持久化的数据,并且在请求的时候按需传递这些数据。

关注与招聘

我们招聘信息都在公众号内,公众号定期发一些闲鱼自己的技术干货,欢迎关注和应聘。

本文使用 mdnice 排版