前端AB实验设计思路与实现原理
作者: 发布于:

背景及痛点

在淘系前端,我们没有一条简洁而高效的AB实验接入方案,过去大多数前端在接到AB需求,唯一的选择就是调用客户端提供的库函数来实现,我们从中发现了一些痛点:

  • 首先前端开发者需要花大量的心思去处理AB的逻辑;
  • 此外客户端实验的作用范围仅局限于端内,并不能覆盖前端所有的场景,比如外投,小程序或PC等等;
  • 数据的精细度不足,不管是服务端实验,还是客户端实验都是页面维度的,而业务方常常需要看更细粒度的指标,比如页面上某个模块的点击率,前端在数据采集上具有天然的优势;

因此,打造一个服务于淘系乃至集团前端的标准化的简单高效的AB实验链路,是我们做这件事的初衷。本文将描述前端AB实验的设计及原理。

设计思路

一条完整的AB实验链路到底需要什么?创建实验,工程接入,分流,实验数据回流...恩,是的,我还可以列一大堆,但是它们没有组织不成体系,就毫无意义,更谈不上架构设计。所以,我们来把这些分散的元素尝试归类。


一条完整的AB实验链路其实可以拆分成两部分,第一部分是实验配置链路,顾名思义它包含了所有与实验配置相关的操作,配置创建,保存,分组,配置的推送或下发,以及使用配置进行实时分流。第二部分是实验数据链路,它包含实验数据的染色,过滤,计算,展示(实验报表)。把这两部分拼成一个闭环,也就是我们设计前端AB实验的核心。

下面给大家分别介绍这两个数据模型。

实验配置模型

我们将实验配置以应用为维度进行组合,然后推送到CDN。前端运行时则通过JSSDK读取配置,完成分流并渲染相应的业务组件。
![Blank Diagram (28).png](https://img.alicdn.com/tfs/TB1fF4gD9f2gK0jSZFPXXXsopXa-989-547.png />应用我们可以理解为一个前端工程,一个小程序可以是一个应用,或手淘内某个活动页面(比如淘金币)也可以是一个应用应用里面包含了场景,一个独立的流量入口我们称之为场景,比如某应用的首页就是一个场景,导购链路也是一个场景。场景同时也是底层的分流模型,这部分我们放在下一个章节再详细介绍场景内部的结构,此处我们只需要知道我们新建的实验是与场景直接关联的。

实验数据模型

从上图我们可以得出,业务是数据指标的集合,并且同一业务下的实验可以创建并关联这个业务域下的所有指标。这些被关联的指标将最终通过数据采集和计算,反映在实验的报表里。
![Blank Diagram (29).png](https://img.alicdn.com/tfs/TB1uGhkD7T2gK0jSZFkXXcIQFXa-989-569.png />举个实际的例子,手淘内的淘金币业务,在淘金币首页(场景)我们想要跑一个实验,AB营销模块哪个更高效?那么营销模块的曝光UV和点击UV就是我们要去创建并关联的指标。该指标也会存在于这个业务域下的某个数据集里。说到这里也许有人会疑惑,如果淘金币既是应用又是业务,为啥还要区分这两个概念?为什么不是应用既下发配置又组合指标呢?原因很简单,因为业务是可以跨应用的,比如手淘某个业务由于业绩出色,决定扩张,在淘宝特价版App里也要上线。那么这个业务就有两个应用了,且一个在手淘一个在手淘特价版,这里配置下发要区分,但是业务指标很多却是一样的,这种情况下,一个业务两个应用是一个相对优化的解决方案。

设计方案

前端AB实验链路的设计,就像是对上述两个模型做的一道“完形填空”。我们从平台侧和运行时两个大的方向入手,将上述模型的各个元素及其关系一一布局,并串联形成闭环。

AB平台




对于第一次入驻AB平台的业务,我们需要进行相关工作空间的注册

  • 应用,用于管理实验配置,即配置的整合及推送
  • 业务,用于管理实验相关的数据指标
  • 场景,分流模型(后面章节详细描述)

创建完工作空间以后,我们开始核心的实验创建链路,这里包含了实验的基本信息的读取,分桶设置(流量分配)。为了进一步降低实验接入的门槛,平台侧在流量分配步骤之后会自动生成前端接入代码,方便前端开发者能快速接入实验。
![37B56BD9-A17E-40E8-9DFE-DB69CAED5B09.png](https://img.alicdn.com/tfs/TB1NyplDYj1gK0jSZFuXXcrHpXa-2434-1296.png />接下来是创建指标并将指标与实验关联的过程(可以回顾下实验数据模型章节)。然后是实验发布,正式发布前,有一个beta发布环节,beta发布可以理解为实验上线前的一次“非正式”下发,开发者和业务方可以在beta发布后对实验分流、数据及相关业务逻辑进行充分验证后,再正式发布。当然,实验是支持多次发布的,即实验中期业务方可以回到流量分配这一步,重新调控流量比例,并重新发布实验。

运行时

JSSDK

我们不妨回顾下上文实验配置模型图中JSSDK的位置,它运行在前端项目中,读取实验配置,实时分流,并根据分流结果返回要渲染的组件。我们再来回顾下实验数据模型,JSSDK在数据这部分要做的工作是什么,答案是实验数据上报。第一,它需要上报分流结果,第二,它需要上报实验所关联的指标数据,即业务要看的实验数据。这么一想我们对于JSSDK要做的事情就比较清晰了,下面我们来聊一聊我们具体的设计方案,及扩展方案。


下图说明了运行时,JSSDK与前端工程及AB平台的关系。先从前端工程说起,我们在工程里引入了一个业务AB实验组件,该组件是AB平台根据用户的配置动态生成的,作为(AB实验相关的)业务组件与页面(或父容器)的衔接器,其核心工作就是读取AB实验所需的参数并传入SDK,以此触发SDK的整个AB实验逻辑
![Blank Diagram (31).png](https://img.alicdn.com/tfs/TB1wd8kD1H2gK0jSZJnXXaT1FXa-1666-1346.png />再来看JSSDK部分(蓝色),首先从全局来看,我们把JSSDK拆成了两个包:

  • 一个是核心(Core)包,封装了通用的核心逻辑如实验配置读取及缓存策略,实验周期控制及分流算法;
  • 另一个是接入具体DSL工程的衔接器包(Coupler),如图所示,它就像是一个AB实验流程的中转站,它实现了一套接口函数,即在特定DSL环境下(如React)的请求、缓存及cookie解析(分流因子),并将这些接口函数和实验参数一起透传给Core,我们这么设计的目的是实现Core与前端DSL的彻底解耦,这样极大的增加了JSSDK的可扩展性。


在拿到实验参数,及所需的接口函数以后,Core要做的工作就是先获取实验配置,此时Core不会直接去请求实验配置,而是触发版本控制策略,该策略主要是检查远程实验配置的版本号是否更新,若有更新才会去请求配置,否则会读取本地缓存的配置。这份本地缓存的配置是用户第一次触发实验时从CDN请求并缓存下来的,之后每次版本更新,才会重新请求并更新缓存。拿到实验配置后,Core会确认当前事前是否处于实验周期(AB平台侧配置)内,校验通过后才会正式触发分流算法(见下一章节),然后将分流结果返回给Coupler。


Coupler接着会根据分流结果来判断应该展示哪个对应的业务组件,同时它会将分流结果上报给平台,用户此时已经可以在实验的实时数据报表看到分流数据了,技术同学可以通过实时数据来确认实验是否正常触发。另外,埋点组件会对命中的业务组件做一层封装,这里会传入可供业务组件调用的埋点上报方法,具体的调用我们在AB平台创建实验时,就已经生成好了,前端同学是需要将这些上报实验指标的代码部署到相应的业务即可。这一部分埋点数据是T+1的,业务方可以在平台侧看到相应的实验报表,并分析实验结果。

扩展方案

前端DSL可谓是百家争鸣,为了覆盖所有的前端场景,我们在设计上慎重的考虑了JSSDK的易扩展性。这也是为什么我们在上一节的Rax 1.0方案中,将JSSDK拆成了Core和Coupler两个包,我们的思路是Core封装AB实验的核心逻辑,不依赖任何前端DSL,在Core与前端工程之间引入一个"衔接器"包,来串联起整条链路。这样如下图所示,我们可以通过这样的架构,非常低成本的扩展到React,小程序,Node FaaS等等。同时也具备良好的可维护性。
![Blank Diagram (32).png](https://img.alicdn.com/tfs/TB1uc8bDWL7gK0jSZFBXXXZZpXa-1188-768.png />其实在上一节中也有提到我们是如何做到将核心包(Core)与这些DSL彻底解耦的,我们定义了一套接口规范,然后在"衔接器(Coupler)"包中根据这个规范实现一系列的接口函数,当Core在执行某段逻辑调用这些函数时,根本无需关心其底层用的是哪一个DSL的API。比如对localStorage的操作,React和小程序的API是完全不一样的,所以我们在React和小程序的"衔接器"中按照接口规范各自实现了这样一套对localStorage的处理函数,并透传给Core。

分流模型

正交和互斥
在第一章节讲实验流量模型的时候我们就提到了场景,我们将一个独立的流量入口定义为场景。我们还举例说,xx应用的首页可以是一个场景,现在我们不妨来拓展下这个例子,xx的首页可能同时运行了多个AB实验:

  • 实验一,红包权益弹层有AB两种样式,观测指标是两个弹层的点击率;
  • 实验二,页面头部商品模块有AB两种样式,观测指标是模块点击率;
  • 实验三,运营投放的营销banner有AB两种设计,透不同利益点,观测指标是banner点击率;

现在的问题是,实验二和实验三都是页面上的模块,我们希望这两个实验同时运行,但不希望它们互相影响,即进入实验二和实验三的流量必须互斥。如何做到呢?对我们而言,场景不仅是一个流量入口,更是一个分流模型,实验在场景里并不是无序放置的,而是通过层(layer)来进行规范。我们可以这么理解,场景是一个纵向的容器,而层是一个横向的容器,实验则按照一定规则放在不同的层里。如下图,实验1独占一层,它与下一层的实验2和实验3是正交关系,即进入实验1的流量,同时也会进入实验2或实验3。我们把实验2和实验3放在同一层,因为我们希望进入实验2的流量不要与进入实验3的流量重叠。这段描述,可以简单总结为,在一个场景里,层与层之间的实验流量正交,层内的实验流量互斥。


实验推全
在上面的模型图中我们还看到一个特殊的层,Launch Layer,这个层用来放被推全的实验分组。比如说,经过线上验证实验1的A组效果明显优于B组,用户在平台侧将A组推至全量,这时候场景内部结构会发生变化,即实验1的A组会从原来的layer被放到这个特殊的Launch Layer里,此时该场景内所有触发实验1的流量将不会再执行分流算法,而是直接返回组A所对应的前端组件。

JSSDK 分流
JSSDK的分流规则遵循上文提到的分流模型,前端AB实验用到分流因子是访问者浏览器cookie下的cna字段,即该用户的web设备id。我们用这个字段来唯一的标识一个用户,其好处是,可以很好的覆盖端内,端外,无线和PC等各种场景,缺点是,同一个用户用不同的web设备来访问前端应用时,有可能会展示不同的分组结果。此外,web设备id无法对判定用户的人群。一种分流因子一定有其局限性,这部分的设计需要是可以扩展的,比如未来可以支持根据用户id,utdid甚至是各种用户自定义分流因子的接入。

数据回流

回到数据链路,在AB平台设计章节我们聊到了创建数据指标并将指标与实验绑定,在这一步我们会给每一个新创建的数据指标分配一个唯一id(UID)用来标识该指标;在前端工程运行时,分流之后展示相应组件,并触发相关的埋点,如下图所示在埋点参数中,我们会带上日志key,埋点类型,实验参数,即实验发布id和分组id,实验发布id是什么呢?前文提到在实验运行中,我们允许业务重新调控流量比例,并发布实验,也就是说一个实验是可以多次发布的,所以实验发布id可以标识采集到的数据是当前实验的某一次发布;当然我们还会带上标识指标的UID。

平台侧,运行定时的数据任务,该任务会从底表中过滤出实验相关数据,上文提到我们在埋点上报时会上报一个日志key,这个key是SDK与数据侧约定的一个key,用来标记这条记录是实验埋点。因为我们的日志底表每天都有大量的日志写入,这个特殊的日志key可以让我们从底表的各种日志中快速过滤出实验相关的日志。然后我们根据上报的参数对数据进行归类,实验发布ID用来标识某个实验的某一次发布,UID标识指标,然后分桶ID用来标识实验的分组,这样我们就可以得到指标对应的UV和PV,AB实验的报表也就生成了。

结语

前端AB实验在淘系乃至集团,还是一块未被充分开垦的土地。我们的应用迭代频繁,但其实我们很少去验证,或者说用科学的方式去验证某一次迭代的真正价值。在大数据崛起的时代,如果我们的每一次迭代不能最终沉淀出数据,而仅仅依靠"经验"恐怕是远远不足够的。前端工程师们,从现在开始我们有了一块阵地,我们应该发挥科学严谨的实验精神,用实验数据去验证迭代,并以此为基础去决策。最后,欢迎感兴趣的朋友进一步交流,可以加我微信MrMarc,期待你的真知灼见。