【第2134期】前端工程师是如何做“资损防控”的?
前言
U1S1就是”资损防控“所吸引的。今日前端早读课文章由阿里@菉竹分享,由@居里先生授权分享。
@石凯,花名菉竹,2017年硕士毕业于杭州电子科技大学,目前任职于阿里巴巴淘系技术部频道与D2C智能组,主要负责营销工具和资损防控工作
正文从这开始~~
资损 —— 顾名思义就是平台发生了与用户或客户心理预期不符、直接或间接产生经济损失的场景。
一直以来,资损问题就在我们的生产环境中不断发生,而且随着业务的规模和疆土不断扩大,经济损失的规模也在不断扩大,这直接对平台、客户和用户都产生了非常不良的影响。尤其在某一时间段连续发生高资损风险问题,恶劣程度上升到集团,对平台的生产和运营产生很高的负面影响,所以大家高度重视资损风险的防控。
本文希望通过我们的思考以及淘系双 11 的实践为大家提供一些资损防控的经验参考,也欢迎大家提出宝贵的意见。
探索之路
在资损防控方面,服务端比前端起步要早,而且做得也非常专业,比如各种离线或实时的容灾幂等检查、链路对账告警、关键配置巡检、关键标巡检等等。
然而对于前端而言,阿里淘系技术部是从 2019 年双 11 前夕开始才开始重视起前端资损防护问题,所以一年前并没有沉淀什么产品化方案,当时能采取的手段就是对案例的总结、对问题的定义、对程度的定级、对红线的定论,通过一些规章制度、学习考试的手段来强化资损防控的文化意识,通过一些人肉盘点、case by case 的人工预演方式来规避资损风险保障业务的稳定。比如下文,就是人工预演这个很直接的土办法的介绍。
人工预演
由于缺少产品化手段,去年的双 11 、双 12 等大促,前端采用的都是人肉盘点、人工预演的土办法来做保障。我们分析了前端所有可能出现的资损风险点,并制定了一套前端的专属资防规章制度和风险编码表,并围绕这些资损风险点,盘点所有参与双11的业务前端代码,对资损问题的识别、预防、止血和恢复过程进行详细的人工预演。
印象中 2019 年双 11,前端 C 端资损防控,总共进行 6 天,每天 2 场(下午与晚上),每场平均 3.5 小时,大概 4 位评委,共进行 73 例预演,每例预演大概 15 分钟且至少 2 位预演者参与,耗费人力总成本均值在 200 小时(折算 25 人日),这个数字相比双 11 整体的人力投入水平来说,也是非常恐怖的。
通过上述方式,过程中我们属实提前发现了一些资损风险问题,虽然最后每场大促线上都没有出现资损问题,但这个土办法在过程中人力和时间消耗还是非常恐怖的,而且防控效果如何完全依赖当时现场的评委 review 的效果好坏。由此可见,人工预演这种方式不仅时间和人力成本过高,而且防护效果有限,并不适合作为长期的防控手段。所以,我们向前探索准备通过一些产品化的手段来解决防控效果和成本问题,就有了如下的一些尝试。
前后端对账
如上文所提,受人工预演方式的成本、防控效果限制,我们去年双 12 之后开始尝试资损防控的产品化方案设计。根据以往权益营销活动出现过的资损案例来看,当消费者看到的权益信息与实际到账的权益信息不一致时,容易引起大面积客户投诉。譬如:
某短视频业务在权益发放时,在消费者领取成功单品优惠券时错误地表达成了红包;
某红包发放业务错误地使用红包列表接口作为中奖结果表达,发现时长长达10.5小时
对于上述这类权益信息前台展示和后端发放不一致的问题,及时的监控报警对于止血、控制资损规模至关重要。为此,我们针对类似问题为业务产品的生产阶段设计了一套前后端对账的产品化方案。
前后端对账的整体思路如下:
对账方案整体涉及采集层统一接入、数据实时处理对账以及报警订阅,具体如下:
接入层:前端封装统一的 SDK ,覆盖 web、weex 和 miniapp,在页面端采集权益的关键信息;
数据层:基于 Blink 进行数据实时处理,存储到 SLS 日志以及 METAQ 消息,并通过后端平台订阅日志消息进行实时对账;
应用层:订阅权益的对账消息并打通实时报警流程,以及通过 SLS 日志,查看权益的实时大盘以及模块治理。
然而,项目上线一段时间后,我们发现效果并不如预期:
一方面,由于前端 SDK 对业务代码是有一定侵入性的,所以各方业务在接入前后端对账时,或多或少还是存在一定的成本。尤其是对一些稳定的线上老业务,反而容易在改造时引入新的问题;
另一方面,前端 SDK 采集的权益信息无法直接从 UI 展现层识别(金额可能被截断),从报警情况来看,发现的问题均是各方的业务开发同学上报错权益字段而非真的前后端权益不一致导致的误报。
从实际表现来看,我们原本期望用前后端对账的方式能够及时发现业务产品在生产阶段中权益信息在前端表达和服务下发不一致的情况,然而这套方案由于前端拿不到 UI 的利益点字段、问题发现率很低且存在一定接入成本等原因,并不能满足我们的需求,我们只能继续探索其他的资损防控手段。所以我们把目光聚焦到业务产品的研发阶段,看看能否从产品的研发阶段中探索出一些资损预防的产品化手段出来,所以就有了以下的尝试。
静态代码扫描
在尝试前后端对账方案不足预期后,我们开始重新思考:人工预演的方式可以帮我们发现潜在的资损风险问题,但其主要问题在于需要投入大量人力和时间成本,那么为什么不想办法降低这个成本呢?
为此,我们从代码的 CcdeReview 过程中摸索出了一种 基于 AST(Abstract Syntax Tree,抽象语法树)的前端代码静态扫描方案,可以在一定程度上规避金额计算、数字造假、数字歧义、文案过期等问题。这种机器替代人为 CodeReview 的方式,不仅省去了人力成本,而且还为 CR 的质量提供了一道基准保障。
静态代码扫描的整体方案思路如下:
其背后具体的原理介绍如下。
AST
在计算机科学中,抽象语法树(Abstract Syntax Tree,AST)或简称语法树(Syntax tree),是源代码语法结构的一种抽象表示。它以树状的形式表现编程语言的语法结构,树上的每个节点都表示源代码中的一种结构。
这是摘自百科上对 AST 的一段解释,我们再来看一个 code ⇌ AST 相互转换的简单示例:
如上所示,代码片段var str = "hello world" 被拆解成了多个部分,最终以一棵树的形式表示出来(如果想查看更多源代码对应的 AST,可以使用神器 astexplorer(https://astexplorer.net/) 在线尝试)。
代码扫描的基础正是建立在仓库代码的 AST 解析和遍历之上的,为此,我们需要借助 Babel 来完成这部分工作
Babel
Babel(https://babel.docschina.org/docs/en/) 其实是一个 JavaScript 编译器,主要用于在旧的浏览器或环境中将 ECMAScript 2015+ 代码转换为向后兼容版本的 JavaScript 代码。
简单来说,为了将 ES2015+ 代码转换成向后兼容版本代码(比如 ES5),Babel 每次都需要先将源代码解析成 AST,然后修改 AST 使其符合 ES5 语法,最后再重新生成代码。总结一下就是 3 个阶段:
parse -> transform -> generate。
由上可以看到 Babel 不但完成了 AST 的解析工作,而且由于其编译 js 代码的使命,它还提供了一套完善的 visitor 插件机制用于扩展。有关 『如何自定义 Babel 插件』,可以查看这份 Babel 插件手册(https://github.com/jamiebuilds/babel-handbook/blob/master/translations/zh-Hans/plugin-handbook.md)。根据手册,我们就可以使用以下代码添加自定义规则来完成代码扫描任务:
const fs = require('fs');
const parser = require('@babel/parser');
const traverse = require('@babel/traverse').default;
// 编写自定义规则插件
const visitor = {
// TODO:待添加
};
// 源代码
const code = fs.readfilesync('your/file');
// code -> ast
const ast = parser.parse(code);
// 用自定义规则遍历 ast (即代码扫描)
traverse(ast, visitor);
自定义规则
介绍完 AST 和 Babel 后,我们再回到资损防控问题上来。根据以往的经验来看,前端容易造成资损/舆情的代码往往有:
金额赋默认值
金额计算
数字造假、固定金额/积分
过期时效文案 …
因此,我们就可以根据上述这些 case 自定义规则来编写 Babel 插件。就拿 『金额赋默认值』 为例,我们可以列举日常代码中的一些 bad cases,然后使用 astexplorer 分析其 AST,最后再针对性地编写匹配规则即可。
// 金额赋默认值 bad cases
// case 1: 直接赋默认值
const price = 10;
// case 2: ES6解构语法赋默认值
const {price = 10} = data;
// case 3: "||"运算符赋默认值
const price = data.price || 10;
// ...
case 1: 直接赋默认值
根据上面的 code vs AST 关系图可以看到,我们只要找到 VariableDeclarator 节点,且同时满足 id 是金额变量,init 是大于 0 的数值节点这两个条件即可。代码如下:
const t = require('@babel/types');
const visitor = {
VariableDeclarator(path) {
const {id, init} = path.node;
if(
t.isIdentifer(id) &&
isPrice(id.name) &&
t.isNumericLiteral(init) &&
init.value > 0
) {
// 直接赋默认值 匹配成功!
}
}
};
case 2: ES6解构语法赋默认值
观察上面的关系图,我们可以得出结论:找到 AssignmentPattern 节点,且同时满足 left 是金额变量,right 是大于 0 的数值节点这两个条件。代码如下:
const t = require('@babel/types');
const visitor = {
VariableDeclarator(path) {
const {id, init} = path.node;
if(
t.isIdentifer(id) &&
isPrice(id.name) &&
t.isNumericLiteral(init) &&
init.value > 0
) {
// 直接赋默认值 匹配成功!
}
}
};
case 3: "||"运算符赋默认值
上图的规则同样也不复杂,但需要注意一点:实际代码中,= 右侧的赋值表达式可能会复杂的多,甚至包含了一些逻辑运算。因此,我们需要改变策略:遍历右侧的赋值表达式中是否包含 "|| 正数" 的模式。代码如下:
const t = require('@babel/types');
const visitor = {
VariableDeclarator(path) {
const {id, init} = path.node;
if(t.isIdentifer(id) && isPrice(id.name)) {
path.traverse({
LogicalExpression(subPath) {
const {operator, right} = subPath.node;
if(
operator === '||' &&
t.isNumericLiteral(right) &&
right.value > 0
) {
// "||"运算符赋默认值 匹配成功!
}
}
});
}
}
};
通过上述的3个例子,我们就已经能把绝大部分 『金额赋默认值』 的代码给扫描出来。其他的一些场景也是如此,只要根据代码生成的 AST 找到规律,然后编写对应的 Babel 插件即可。
小结
然而,静态代码扫描工具虽然能够帮助我们从代码层面上发现一些共性问题,但面对不同的业务逻辑仍然是没有感知的,以至于不容易挖掘出代码中深层次的问题。因此,在面对 UI、多态、复杂交互逻辑等场景时,纯靠静态代码扫描不足以完全解决问题。所以在第一道防护工序之后,我们又设计了第二道防护工序,具体介绍如下。
UI 测试扫描
根据以往发生的资损故障来看,问题往往多发生在代码变更时,开发同学的新改动影响到了业务的原有功能,而测试同学又恰好没有回归到这点。对测试同学而言,业务每次的全量回归工作量是巨大且重复的。就拿领取红包的例子来说,不同的账号(人群)、领取成功、网络超时、重复领取、没有资格、服务故障等等都是资损通常需要考虑在内的测试用例。此时,如果能有一个 UI 自动化回归测试工具,既能为业务提供一个保障,又能解放测试同学。然而,传统的 UI 自动化测试需要开发同学编写对应的自动化测试代码,不但有上手成本,而且还造成了额外的工作负担。
为此,我们又提出一种 基于录制/回放的 UI 测试扫描方案:开发/测试同学只需提供一个可正常访问的页面地址,正常的功能测试录成测试用例,在项目发布的时候就会进行一次页面回放,最后再通过 UI 测试用例快照比对的方式判定本次功能回归是否通过。
UI 测试扫描的整体思路如下:
其背后的原理介绍如下。
页面代理
代理页面本质是一个 web 服务,它通过 url 参数方式接收原始页面链接和注入脚本地址,由服务器请求原始页面返回对应的 html 文档,并且在返回文档头部注入接口拦截脚本、调试工具脚本以及url参数中取得的自定义注入脚本。代理页面拥有和原始页一样的 html ,同时也会添加上原始页的 query 参数,直接访问代理页除了 js 的 location 变量,其他环境和原页面相差无几。至于 location 变量,试了很多方案,发现都改写不了,一重写页面就跳转,多次尝试无果后我们发现可以换个思路解这个问题,既然重写不了我们就替换掉它,我们将页面所有的 script 脚本包了一层 with,如下图所示,我们将页面内所有的js脚本的上下文改写,使之读取的 location 是被我们重写的,从而达到代理页的渲染运行结果和原始页一致的效果。
with({ location: $proxyLocation }) {
// js code
// ...
}
录制脚本
通过上述的页面代理,我们就可以在访问原页面的时候注入我们的录制脚本。为了能够支持页面的回放,录制的时候就要提供两份数据:
录制期间产生的所有网络访问数据
用户操作数据(包括点击、滚动等)
要想记录网络访问的数据并不难,此处不展开多述,可以借用 AOP 的思想,在网络请求的回调中增加一个 interceptor ,同时保存下本次请求的 url,param,response,以便回放的时候匹配使用。
再来看下又该如何劫持用户的操作数据,其实在 h5 页面中,用户的任何触摸操作都会依次触发 onTouchStart、onTouchMove、onTouchEnd 事件,所以我们只需拦截这三个事件对应的 targetEvent 参数即可。就拿拦截 onTouchStart 为例:
document.addEventListener('touchstart', (evt) => {
const touch = _.get(evt, 'targetTouches.0', {});
const selector = DomUtils.getSelector(evt.target);
if(shouldHijack(selector)) {
const now = Date.now();
touchQueue.push({
selector,
scrollTop,
timestamp: now,
action: 'touchstart',
position: {
pageX: touch.pageX,
pageY: touch.pageY + this.getScrollTop(),
}
});
this.lastAction = 'touchStart';
}
});
如上可以看到,我们记录下了触发事件的事件类型、节点选择器、时间戳、页面距离顶部高度以及坐标位置等信息,这些都是回放时必不可少的数据。
再来看页面的滚动拦截,只要劫持 onScroll 事件即可。不过这里需要注意一点,页面滚动分两种:手指接触屏幕时的拖动,手指离开屏幕后的惯性滚动。前者会同时触发 onTouchMove 和 onScroll,而后者只会触发 onScroll。
document.addEventListener('scroll', (evt) => {
if(!this.isRecording || this.lastAction === 'touchmove') return;
this.touchQueue.push({
action: 'scroll',
timestamp: Date.now(),
scrollTop: this.getScrollTop(),
});
this.lastAction = 'scroll';
};
回放脚本
回放脚本同样依赖代理页面的注入能力,但做的事刚好和录制脚本相反:
拦截请求,使用录制时记录的网络数据 mock
按照时序依次派发 touchEvent 事件
类似地,拦截请求和录制时的原理大体一致,只需根据本次请求的请求参数从录制时拦截的数据中找到对应的匹配即可。
再来看下如何根据时序派发 touchEvent 事件来模拟回放,核心代码如下:
let isPlaying = false;
async function replay() {
isPlaying = true;
const {touchQueue = []} = await load('recordData');
while(isPlaying && touchQueue.length > 0) {
const item = touchQueue.shift();
const {action, delayTime, scrollTop} = item;
await sleep(delayTime);
setScrollTop(scrollTop);
if(action !== 'scroll') {
dispatchEvent(item);
}
}
}
派发事件是基于 dom 的 dispatchEvent 方法,可以参考 [MDN(https://developer.mozilla.org/en-US/docs/Web/API/EventTarget/dispatchEvent) 文档,代码如下:
function dispatchEvent(info) {
const {selector, action, position: {pageX, pageY}} = info;
// 模拟事件触发(touchstart, touchmove, touchend)
const element = document.querySelector(selector);
if(!element) {
console.warn(`element with selector ${selector} not found`);
return;
}
const touchObj = new Touch({
identifier: Date.now(),
target: element,
clientX: pageX,
clientY: pageY,
pageX: pageX,
pageY: pageY,
radiusX: 2.5,
radiusY: 2.5,
rotationAngle: 10,
force: 0.5,
});
const touchEvent = new TouchEvent(action, {
bubbles: true,
shiftKey: true,
cancelable: true,
touches: [touchObj],
targetTouches: [touchObj],
changedTouches: [touchObj],
});
element.dispatchEvent(touchEvent);
}
快照对比
难点分析
根据前文的介绍,我们已经可以分别拿到录制和回放的最后一帧快照,所以接下来我们就希望设计出一套算法对这两个快照做内容一致性的自动化判断。从不同的输入案例来看,我们主要面临以下的几个难点:
相同文字在不同型号的手机上会有不同的字体字号显示,像素级比对会将相同文字误判为不一致。
对于红包等弹层图片,只需关注红包弹层信息是否一致,无关的背景会导致模型误判。
由于回放/录制在时间戳上无法保证严格一致性,两张快照往往存在位置上的位移偏差。
算法设计
整体的算法流程如下图所示,下面我们分步骤阐述算法思路。
首先对两张快照计算 SIFT 相似度进行初筛。匹配的核心问题是将同一目标在不同时间、不同分辨率、不同光照、不同方向的情况下所成的像对应起来。图像的局部特征,对旋转、尺度缩放、亮度变化保持不变,对视角变化、仿射变换、噪声也保持一定程度的稳定性。相似度低于阈值的两张快照判为不通过,通过快照会做进一步精细比对。
针对弹层图片,需要预先对背景等无关信息做去除,仅保留弹层信息。这边先将图片从 RGB 空间转换成 LUV 空间。L 分量会保留图片的亮度信息,便于根据亮度值二值化图片,去除无效背景,效果如下。
将预处理好的干净图片送到 OCR(光学字符识别) 模型,提取出文字内容及相对应的坐标信息。针对像素过小的文字信息进行删除,往往是噪声产生的错误信息。
按照返回的坐标信息进行文字的位置还原,方便下一步做内容比对。
由上一步产出的结果进行内容比对,在图上标注出两张快照不一致的地方作为输出,算法结束。
效果演示
不通过:
通过:
小结
诚然,我们通过 UI 扫描工具改变了传统编写 UI 测试代码的方式,测试同学只需在功能测试的时候顺便录制一份测试用例即可,这不但降低了测试同学的自动化的学习成本和回归的时间成本,而且还为每次业务的发布提供了一道自动化回归保障。不过,在本次双 11 前端资防工作实际落地中,我们仍然遇到了一些问题:
UI 测试扫描目前暂时只支持 h5,还不兼容淘系内的其他一些前端技术栈(比如 weex、直播、小程序等),导致这些业务仍然只能通过人工 review 的方式保障;
我们虽然对快照对比的算法进行了优化,但在实际应用中仍遇到一些由于算法判断不准导致误判的情况。因此,我们还将继续优化快照对比算法,进一步提升判断的准确度。
目前的快照对比只校验了录制和回放的最后一帧(即最终状态),大量的中间状态信息没有被利用起来,从而丢失了过程的校验。因此,后续我们将考虑引入视频的对比算法,达到真正的录制/回放全对比。
基于目前的页面代理机制,录制功能只支持当前页的操作录制,对于页面跳转类的测试用例还无法覆盖。因此,我们接下来也将继续升级页面代理和录制/回放脚本,支持链路层面的测试用例覆盖。
以上遇到的这些问题均是我们接下来继续重点突破的挑战。
总结与展望
如前文所述,淘系的前端资防工作一年内悄然变化着,从最初的人工预演到目前的三道递进式的产品化防控手段:
【已产品化】针对可以在代码级别静态扫描分析出来的资损风险问题,做了第一道产品化防控手段——资损/舆情风险代码扫描工具;
【已产品化】针对 UI、多态、复杂交互逻辑等不能从代码级别分析出来的资损风险问题,与测试团队合作做了第二道产品化防控手段——资损/舆情风险 UI 测试扫描,通过 UI 测试用例快照比对预防资损风险;
【兜底方案】针对上述产品化手段不能覆盖的特殊场景,暂时先依赖人工预演作为兜底防护方案。
相比于去年的双 11 资防工作,今年我们依靠上述方案甚至取消了人工预演的环节。预演者也从去年需要准备相关文档(如止血方案、预案等)变成今年录制 UI 测试用例,其中需要准备的时间成本几乎打平,但大大节省了预演的参会时间;除此之外,预防效果也因其范围更聚焦、防护组合更全面,要比去年效果更佳。当然了,目前的这些方案都还只是预防手段,无法百分之百保障线上不会发生资损故障,每个人对于资防的态度仍不能掉以轻心。
在接下来的工作中,考虑到目前的资防方案仅能做代码缺陷方面的预防,产品设计、运营配置等方向还没有实质性的防控方法,所以后续我们将在构思链路级别的、生产环境以及运营环境上的防控手段,建设一些告警和自动止血机制为平台保驾护航。
关于本文 作者:@菉竹 原文:https://mp.weixin.qq.com/s/ZKtS47uOtjEHdEjKw0Y-uQ
为你推荐
欢迎自荐投稿,前端早读课等你来