前言
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 之後開始嘗試資損防控的產品化方案設計。根據以往權益營銷活動出現過的資損案例來看,當消費者看到的權益信息與實際到帳的權益信息不一致時,容易引起大面積客戶投訴。譬如:
對於上述這類權益信息前臺展示和後端發放不一致的問題,及時的監控報警對於止血、控制資損規模至關重要。為此,我們針對類似問題為業務產品的生產階段設計了一套前後端對帳的產品化方案。
前後端對帳的整體思路如下:
對帳方案整體涉及採集層統一接入、數據實時處理對帳以及報警訂閱,具體如下:
接入層:前端封裝統一的 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 來完成這部分工作
BabelBabel(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
為你推薦
【第2130期】前端元編程——使用註解加速你的前端開發
【第2118期】前端安全生產在ICBU的探索與落地
歡迎自薦投稿,前端早讀課等你來