我們一起聊聊審核平臺(tái)前端新老倉(cāng)庫(kù)遷移
背景
審核平臺(tái)接入50+業(yè)務(wù),提供在線審核及離線質(zhì)檢、新人培訓(xùn)等核心能力,同時(shí)提供數(shù)據(jù)報(bào)表、資源追蹤、知識(shí)庫(kù)等工具。隨著平臺(tái)的飛速發(fā)展,越來(lái)越多的新業(yè)務(wù)正在或即將接入審核平臺(tái),日均頁(yè)面瀏覽量為百萬(wàn)級(jí)別。如今審核平臺(tái)已是公司內(nèi)容生產(chǎn)鏈路上的關(guān)鍵一環(huán),是保障內(nèi)容安全的重要防線,因此穩(wěn)定性至關(guān)重要。
過去一年我們?cè)鴮?duì)前端項(xiàng)目進(jìn)行框架升級(jí),考慮風(fēng)險(xiǎn)與成本最小化,選擇了漸進(jìn)式升級(jí),利用微前端實(shí)現(xiàn)Vue2和Vue3共存,新接業(yè)務(wù)在Vue3倉(cāng)庫(kù)中開發(fā)。經(jīng)過一年的迭代,Vue3項(xiàng)目趨于穩(wěn)定,沉淀了大部分通用能力。為了降低多倉(cāng)庫(kù)維護(hù)心智,同時(shí)解決核心模塊的技術(shù)債務(wù),考慮將剩余活躍代碼進(jìn)行重構(gòu)并遷移至新倉(cāng)庫(kù)。
面向遷移的重構(gòu) - 整潔架構(gòu)在前端的應(yīng)用
案例選擇
參考前端埋點(diǎn)報(bào)表,選擇老倉(cāng)庫(kù)中頁(yè)面維度訪問量最高的路由,對(duì)線上使用情況進(jìn)行摸排。日常業(yè)務(wù)現(xiàn)狀是點(diǎn)直融合,直播業(yè)務(wù)配置化接入需求較多,因?yàn)闃I(yè)務(wù)形態(tài)的差異,定制需求多,現(xiàn)有配置能力無(wú)法滿足,需擴(kuò)充。開發(fā)現(xiàn)狀是通用配置化代碼改動(dòng)頻繁,邏輯復(fù)雜,開發(fā)門檻較高,影響范圍大,牽一發(fā)而動(dòng)全身。因此選擇配置化詳情頁(yè)作為優(yōu)先重構(gòu)并遷移的對(duì)象。
配置化詳情頁(yè)采用的是業(yè)務(wù)定制化的低代碼方案,包含schema渲染器和任務(wù)流兩部分。當(dāng)前已沉淀近百份json schema,托管在內(nèi)部其他低代碼平臺(tái)上。頁(yè)面覆蓋40多個(gè)業(yè)務(wù),占據(jù)平臺(tái)約20%訪問量和35%獨(dú)立訪客。
圖片
圖片
如果將頁(yè)面看做一個(gè)黑盒子,依據(jù)唯一標(biāo)識(shí)(路由path和query等)從node服務(wù)、平臺(tái)服務(wù)以及外部業(yè)務(wù)方服務(wù)獲取數(shù)據(jù),基于頁(yè)面內(nèi)部規(guī)則渲染頁(yè)面。審核員瀏覽并進(jìn)行通過、駁回等操作,提交后將對(duì)視頻、彈幕等業(yè)務(wù)資源產(chǎn)生影響。
圖片
schema渲染器基于json schema和接口數(shù)據(jù),在平臺(tái)內(nèi)生成路由信息與頁(yè)面內(nèi)容,負(fù)責(zé)各種模式的頁(yè)面分發(fā)、物料分發(fā),并提供敏感詞、快照、洗數(shù)等通用平臺(tái)能力。
代碼現(xiàn)狀是數(shù)據(jù)獲取、提交操作和頁(yè)面復(fù)雜邏輯分散在vue文件和store中,業(yè)務(wù)邏輯和UI框架耦合嚴(yán)重,不利于集成自動(dòng)化測(cè)試和框架升級(jí)。待辦、任務(wù)、資源等邊界劃分不清晰,平鋪在“巨石store“中,維護(hù)成本極高且代碼改動(dòng)風(fēng)險(xiǎn)大。渲染器和任務(wù)流邏輯不夠內(nèi)聚,耦合嚴(yán)重,無(wú)法做到關(guān)注點(diǎn)分離。因此需要尋找一種合適的架構(gòu)進(jìn)行重構(gòu),減弱業(yè)務(wù)邏輯對(duì)UI框架的依賴,增強(qiáng)可測(cè)試性。
整潔架構(gòu)
整潔架構(gòu)由Robert C. Martin在2012年提出,核心思想是將軟件系統(tǒng)拆分為獨(dú)立的層次,以實(shí)現(xiàn)高內(nèi)聚、低耦合、可測(cè)試和可維護(hù)。
圖片
一共分為四個(gè)層級(jí),環(huán)與環(huán)之間,存在一個(gè)依賴關(guān)系原則:源代碼中的依賴關(guān)系,必須只指向同心圓的內(nèi)層,即由低層機(jī)制指向高級(jí)策略。
- 實(shí)體層:包含業(yè)務(wù)領(lǐng)域的核心概念和業(yè)務(wù)邏輯。
- 用例層:實(shí)現(xiàn)特定的業(yè)務(wù)用例,將實(shí)體層的業(yè)務(wù)邏輯與具體的應(yīng)用場(chǎng)景結(jié)合起來(lái)。
- 接口適配器層:負(fù)責(zé)處理與外部系統(tǒng)的交互比如用戶界面、數(shù)據(jù)庫(kù)、web服務(wù)等。將外部系統(tǒng)的請(qǐng)求和數(shù)據(jù)格式轉(zhuǎn)化為用例層和實(shí)體層能夠理解和處理的對(duì)象。
- 框架和驅(qū)動(dòng)層:包含具體的框架和工具,比如web框架、數(shù)據(jù)庫(kù)驅(qū)動(dòng)等。
優(yōu)點(diǎn)是可以在沒有UI、數(shù)據(jù)庫(kù)、web服務(wù)器或其他外部基礎(chǔ)設(shè)施的情況下測(cè)試業(yè)務(wù)邏輯;降低對(duì)UI框架的依賴,比如跨端開發(fā)時(shí),業(yè)務(wù)邏輯可以復(fù)用,只需要做UI層的適配。相應(yīng)的,缺點(diǎn)也很明顯,過于復(fù)雜,數(shù)據(jù)需要經(jīng)過多層處理。學(xué)習(xí)成本較高,容易過度設(shè)計(jì),增加復(fù)雜性,靈活性較低。適用于大型復(fù)雜項(xiàng)目,對(duì)于需要長(zhǎng)期維護(hù)和持續(xù)開發(fā)的項(xiàng)目,清晰層次結(jié)構(gòu)和明確依賴關(guān)系有助于減少代碼腐化,更容易適應(yīng)需求變化。針對(duì)我們選擇的模塊,審核前端配置化頁(yè)面,比較適合。
重構(gòu)
圖片
圖片
實(shí)體層
圖片
主要可拆分成待辦、任務(wù)、資源等實(shí)體。待辦實(shí)體,主要提供待辦的基礎(chǔ)信息、獲取配置等屬性和方法。任務(wù)實(shí)體提供任務(wù)的狀態(tài)、任務(wù)耗時(shí)、調(diào)度配置、任務(wù)數(shù)據(jù),計(jì)時(shí)和拉取任務(wù)流程等。資源實(shí)體提供資源的詳情數(shù)據(jù)、獲取詳情及數(shù)據(jù)清洗方法等。待辦實(shí)體包含了配置詳情頁(yè)所需的核心數(shù)據(jù),任務(wù)實(shí)體高度抽象了核心任務(wù)流。在新業(yè)務(wù)接入過程中,實(shí)體層一般不變動(dòng),通過依賴倒置劃分架構(gòu)邊界。
// entities/todo.js
export default class Todo {
todoId
businessId
todoConfig
...
constructor() {}
async getTodoConfig() {
// 獲取配置
}
}
// entities/task.js
export default class Task {
dispatch_conf
listData
timeCount
...
constructor() {}
async getTaskDetail({ getTask, taskFormat, afterGetTask}) {
// 抽象封裝核心任務(wù)流程
}
// 計(jì)時(shí)邏輯
startTimer() {}
clearTimers() {}
}
// entities/resource.js
export default class Resource {
detail
dataReady
constructor() {}
async getResourceDetail({ getResource, resourceFormat, afterGetResource }) {
// 抽象封裝資源模式核心流程
}
}
用例層
用例層針對(duì)洗數(shù)、提交等復(fù)雜場(chǎng)景,通過調(diào)用實(shí)體層來(lái)實(shí)現(xiàn)特定的業(yè)務(wù)邏輯,是適配器層與實(shí)體層的中介。例如封裝了基于配置的洗數(shù)中間件,列表模式的單個(gè)和批量提交,卡片模式的單個(gè)和批量提交,快照上報(bào)、自動(dòng)化質(zhì)檢的復(fù)雜邏輯。用例層包含了系統(tǒng)的復(fù)雜業(yè)務(wù)邏輯,為單元測(cè)試提供了便利,可以獨(dú)立于UI和外部系統(tǒng)進(jìn)行測(cè)試。
// usecase/use-single-submit
import { get } from 'lodash-es' // 三方工具庫(kù)
import { ANNOTATION_SINGLE_OPER_PASS_NAME } from '@/constants' // 常量
import { workbenchApi } from '@/api'
import { setLogData } from '@/utils/xx' // 工具函數(shù)
export function getSingleSubmitParams({ data, state.xxx }) {
//... 邏輯處理
return params
}
export function submitAuditSingle({ data, afterTaskSubmit }) {
const params = ...
workbenchApi.submit(params).then((res) => {
if(res.code = xxx){
afterTaskSubmit() // 調(diào)用鉤子函數(shù)
}
})
}
適配器層
適配器層包含store和UI,調(diào)用用例層的代碼,主要負(fù)責(zé)將依賴UI、外部服務(wù)、設(shè)備等的數(shù)據(jù)處理為用例層可以使用的“干凈數(shù)據(jù)”。適配器層一般不包括復(fù)雜的業(yè)務(wù)邏輯,因此在框架遷移時(shí)僅需關(guān)注基本的框架差異,適合自動(dòng)化代碼轉(zhuǎn)換。
// store/todoConfigDetail
import { getTodoInfo } from '@/struct/TodoConfigDetailStruct/usecase/use-todo'
import { getTaskInfo, getTask, taskDispatchListFormat } from '@/struct/TodoConfigDetailStruct/usecase/use-task'
import { getSingleSubmitParams, submitAuditSingle } from '@/struct/TodoConfigDetailStruct/usecase/use-single-submit'
const todo = ref({})
async function init({ $route }) {
const query = $route.query
const todoId = +query.todo_id
...
set(todo, getTodoInfo({ todoId, ... }))
await get(todo).getTodoConfig()
...
}
function getTaskDetail() {
get(task).getTaskDetail({
getTask: async ({ noSeize, drillTaskIds }) => await getTask({ todo: get(todo), noSeize, drillTaskIds }),
taskFormat: async (data) => await taskDispatchListFormat({ data, schema: get(todo).schema })
afterGetTask: (res) => { ... }
})
}
async function submit(data) {
if (single) {
const params = getSingleSubmitParams({ data, todo: get(todo) })
submitAuditSingle({ params, afterTaskSubmit: () => { ... } })
} else {
...
}
}
// Audit.vue
const todoConfigDetailStore = useTodoConfigDetailStore()
const { todo, task, multipleSelection } = storeToRefs(todoConfigDetailStore)
const { getTaskDetail, submit } = todoConfigDetailStore
新業(yè)務(wù)接入一般對(duì)實(shí)體層和用例層無(wú)改動(dòng),僅需適配器層增加相應(yīng)的展示物料,盡可能避免“牽一發(fā)動(dòng)全身”。新架構(gòu)會(huì)有一定的初學(xué)成本,但結(jié)合審核平臺(tái)復(fù)雜的項(xiàng)目現(xiàn)狀和持續(xù)接入新業(yè)務(wù)的節(jié)奏,長(zhǎng)遠(yuǎn)來(lái)看對(duì)于系統(tǒng)穩(wěn)定性、可測(cè)試性有一定幫助,同時(shí)降低UI框架依賴性。
基于新的架構(gòu),可分層進(jìn)行自動(dòng)化測(cè)試和自動(dòng)代碼轉(zhuǎn)換。
完善用例層的單元測(cè)試
用例層為純函數(shù),不依賴框架、設(shè)備、三方服務(wù)等。單元測(cè)試的技術(shù)棧為jest和vue-test-utils,從審核員的基本工作模式入手,針對(duì)任務(wù)領(lǐng)取、數(shù)據(jù)清洗與展示、稿件處理三個(gè)環(huán)節(jié)完善測(cè)試用例。因?yàn)槭切吕蟼}(cāng)庫(kù)遷移,所以可以將線上環(huán)境視為基準(zhǔn)進(jìn)行用例采集。根據(jù)業(yè)務(wù)重要性、線上訪問情況,按優(yōu)先級(jí)執(zhí)行測(cè)試。單測(cè)能有效降低回歸成本,在新業(yè)務(wù)持續(xù)接入的背景下,保障系統(tǒng)穩(wěn)定性。
適配器層的自動(dòng)代碼轉(zhuǎn)換 - 基于gogocode將vue2升級(jí)為vue3 setup語(yǔ)法
調(diào)研與分析
在尋求升級(jí)方案的過程中,我們對(duì)比了兩款工具:Gogocode 和 vue2-to-composition-api。以下是它們的簡(jiǎn)要對(duì)比:
功能 | Gogocode | vue2-to-composition-api |
缺點(diǎn) | 默認(rèn)不支持轉(zhuǎn)換成 Vue3 setup 語(yǔ)法 | 不支持 template 轉(zhuǎn)換 |
轉(zhuǎn)換規(guī)則覆蓋 | 轉(zhuǎn)換規(guī)則列表 | 轉(zhuǎn)換效果 |
特性 | 像 jQuery 一樣修改AST | 將 Vue2 代碼轉(zhuǎn)換為 Vue 3 的 Composition API 格式 |
2. jQuery-like API 簡(jiǎn)化了 AST 修改成本 | 2. 支持在線轉(zhuǎn)換 | |
優(yōu)點(diǎn) | 1. 支持自定義插件 | 1. 支持轉(zhuǎn)換成 Vue3 setup 語(yǔ)法 |
經(jīng)過調(diào)研,gogocode 是基于 AST 封裝的庫(kù)可擴(kuò)展空間大,但是默認(rèn) gogocode-plugin-vue 不支持轉(zhuǎn)換成 Composition API
vue2-to-composition-api 倒是支持 Composition API,但是不支持 template 部分的轉(zhuǎn)換。
考慮到我們的項(xiàng)目中有許多自定義的轉(zhuǎn)換邏輯,如 UI 庫(kù)替換、store 替換等,我們最終決定使用 gogocode 作為主要工具,并結(jié)合其他手段來(lái)實(shí)現(xiàn) vue2 到 vue3 的全面升級(jí)。
實(shí)施與探索
基礎(chǔ):升級(jí)語(yǔ)法
升級(jí)語(yǔ)法是借助 gogocode 的 replace 方法實(shí)現(xiàn)的,通過 $$$ 匹配符保留所需要的代碼塊,將 Vue2 的語(yǔ)法快速替換成 Vue3 的語(yǔ)法。
// 替換data
scriptAst.replace("data() {return {$$$};}", `const $data = reactive({$$$})`);
// 替換props
scriptAst.replace("props:{$$$}", "const props = defineProps({$$$})");
// 替換生命周期
scriptAst.replace("created(){$$$}", "onBeforeMount(()=>{$$$})")
.replace("mounted(){$$$}", "onMounted(()=>{$$$})")
.replace("async mounted(){$$$}", "onMounted(async ()=>{$$$})")
.replace("beforeUnmount(){$$$}", "onBeforeUnmount(()=>{$$$})")
.replace("unmounted(){$$$}", "onUnmounted(()=>{$$$})")
.replace("beforeDestroy(){$$$}", "onBeforeUnmount(()=>{$$$})")
.replace("destoryed(){$$$}", "onUnmounted(()=>{$$$})");
效果如下,通過 replace 替換的方式適合大部分場(chǎng)景,比如 methods、filters、watch 等
圖片
進(jìn)階:處理模板中的變量、函數(shù)中的this變量
template綁定的變量可能是data,可能是props,還可能是 methods
想要替換 template 中的變量,需要先收集 data、props、methods 中的 keys
getDataKeys() {
const keys = new Set();
// 只需要第一層的key,所以deep設(shè)為1
this.scriptAst.find('data() {$$$}').find('$_$:$_$', { deep: 1 }).each(node => {
if (node.match[0] && node.match[0][0].node.type === 'Identifier') {
keys.add(node.match[0][0].value);
}
});
return Array.from(keys)
}
getPropsKeys() {
const keys = new Set();
this.scriptAst.find('props: {$$$1}', { deep: 1 }).each((node) => {
if (node.match['$$$1']) {
node.match['$$$1'].forEach((item) => {
if (item.key && item.key.type === 'Identifier') {
keys.add(item.key.name)
}
})
}
});
return Array.from(keys)
}
// methods 有點(diǎn)小復(fù)雜,需要考慮異步函數(shù),普通函數(shù)和鍵值對(duì)的寫法
getMethodsKeys() {
const methodsAst = this.scriptAst.find('methods:{$$$}');
const methods = methodsAst.find('$_$() {$$$1}');
const asyncmMethods = methodsAst.find('async $_$(){$$$1}');
const mapKeys = methodsAst.find('$_$:$$$1', { deep: 1 });
const methodNames = [];
methods.each(node => {
if (node.match[0] && node.match[0][0]) {
methodNames.push(node.match[0][0].value);
}
});
asyncmMethods.each(node => {
if (node.match[0] && node.match[0][0]) {
methodNames.push(node.match[0][0].value);
}
});
mapKeys.each(node => {
if (node.match[0] && node.match[0][0]) {
methodNames.push(node.match[0][0].value);
}
});
return methodNames;
}
收集完成后可以開始遍歷 template 中的 attr,并替換所綁定的變量了。
handlTemplate() {
// 替換attr, 例如 <div :value="value"></div>
this.ast.find("<template></template>").find(`<$_$ ="$$$0" >$$$1</$_$>`).each((node) => {
node.match['$$$0'].forEach(attr => {
if (attr && attr.value) {
this.dataKeys.some(keyName => {
const reg = new RegExp(`${keyName}\\b`, 'g')
const macth = reg.test(attr.value.content);
attr.value.content = attr.value.content.replace(reg, `$data.${keyName}`)
if (macth) {
return true;
}
})
this.methodsKeys.some(keyName => {
const reg = new RegExp(`\\b${keyName}\\b`, 'g')
const macth = reg.test(attr.value.content);
attr.value.content = attr.value.content.replace(reg, `methods.${keyName}`)
if (macth) {
return true;
}
})
this.propsKeys.some(keyName => {
const reg = new RegExp(`\\b${keyName}\\b`, 'g')
const macth = reg.test(attr.value.content);
attr.value.content = attr.value.content.replace(reg, `props.${keyName}`)
if (macth) {
return true;
}
})
}
})
// 替換content,例如:<div>{{value}}<div>
node.match['$$$1'].forEach(node => {
if (node.content && node.content.value) {
// 省略:與上面類似
}
})
})
}
說到 this 替換也是一個(gè)繁瑣的問題,this.xx, xx 可以是 data,可以是props,可以是 function,還可以是私有屬性等。
所以我們需要先把組件中的 data、props、methods、mapGetter 中的 keys 都收集一遍,然后再替換 script 中的 this 變量。
// 正則替換更方便,所以需要放在最后一步替換
handlThis(code) {
code = code.replace(/this\.([_$0-9a-zA-Z]+)/g, (match, $1) => {
// 替換function body 中的 data引用
if (this.dataKeys.includes($1)) {
return `$data.${$1}`;
}
// 替換function body 中的 methods調(diào)用
else if (this.methodsKeys.includes($1)) {
return `methods.${$1}`;
}
// 替換 vm 私有屬性
else if ($1 && $1[0] === '$') {
return `$vm.${$1}`;
} else if (this.computedKeys.includes($1)) {
return $1
} else if (this.propsKeys.includes($1)) {
return `props.${$1}`
}
return `$vm.${$1}`
})
// 替換function body 中的 動(dòng)態(tài)methods調(diào)用
code = code.replace(/this\[(.+)\]/g, (match, $1) => {
return `methods[${$1}]`;
})
return code
}
進(jìn)階:動(dòng)態(tài)調(diào)用this.xxx該如何解決
async parseOpenDialog(payload) {
const { schema, data } = payload
await this[schema.method](
parseSchema,
data,
dialogParams,
dialogType
)
}
按 vue3 新的寫法,一般是展開的
const parseOpenDialog = async (payload) => {
}
但是按這個(gè)習(xí)慣來(lái)轉(zhuǎn)換,就無(wú)法做到動(dòng)態(tài)調(diào)用this.xxx,我們可以嘗試把方法都放在methods對(duì)象中,有點(diǎn)類似 vue2
const methods = {
parseOpenDialog: async (payload) => {
},
xxx: async() => {
}
}
在替換 this 時(shí),將 this 替換成 methods 變成 methods[schema.method]()。
其他技巧
1 . 原來(lái) vue2 中肯很多屬性掛在組件實(shí)例上,比如 $route, $router, $emit 甚至自定義的屬性等等。下面是 vue3 中獲取組件實(shí)例的方法。
import { getCurrentInstance } from 'vue'
const { proxy: $vm } = getCurrentInstance()
$vm.xxx = '自定義屬性'
2 . 原來(lái)使用的 vuex,現(xiàn)在使用的是 pinia。我們需要先收集 ...mapState($_$, [$$$1]),然后使用 storeToRefs 代替
// 獲取store 使用storeToRefs
if (this.storeType === 'pinia') {
this.scriptAst
.find("computed:{}")
.before(`const {${stateNames.join(',')}} = storeToRefs(${this.getPiniaStoreName(key)})`)
.replace("computed:{$$$}", "$$$")
}
最終將上述轉(zhuǎn)換能力封裝成一個(gè)庫(kù),通過 npm 安裝來(lái)實(shí)現(xiàn)組件批量升級(jí)。
遷移前后的E2E測(cè)試 - 視覺輔助UI自動(dòng)化測(cè)試
端到端測(cè)試是確保新舊系統(tǒng)平穩(wěn)過渡的關(guān)鍵步驟。本次遷移依舊遵循漸進(jìn)式升級(jí)的原則,新增v3路由,線上新老路由共存。共分為功能測(cè)試、UI測(cè)試、性能測(cè)試三個(gè)部分。功能測(cè)試為單元測(cè)試的補(bǔ)充,主要驗(yàn)證新老路由下核心操作路徑及提交參數(shù)的一致性,攔截請(qǐng)求避免對(duì)線上造成影響。性能由通用監(jiān)控大盤進(jìn)行保障。UI對(duì)比測(cè)試是本次的重點(diǎn)。
新老倉(cāng)庫(kù)分別基于Element UI和Element Plus,Element Plus重新設(shè)計(jì)了組件以適應(yīng)Vue3,組件尺寸體系調(diào)整為更自然的大中小選項(xiàng)。間距優(yōu)化為更通用的4px體系,主要涉及 padding 和 margin 屬性修改、 font-size 等字體和圖標(biāo)大小修改等。因此,雖然大部分組件在外觀上保持相似,視覺和布局上可能有一些差異。由于業(yè)務(wù)組件具備一定復(fù)雜性,手寫測(cè)試用例工作量繁瑣,新舊頁(yè)面組件可能存在差異無(wú)法完全復(fù)用,方案也不具備通用性。因此考慮使用計(jì)算機(jī)視覺技術(shù)來(lái)識(shí)別和驗(yàn)證用戶界面的元素。
基于公司內(nèi)部的自動(dòng)化測(cè)試平臺(tái),測(cè)試框架為Playwright,測(cè)試語(yǔ)言選擇Python以更好的利用豐富的圖像處理庫(kù)。指定CSS選擇器,隨機(jī)選擇頁(yè)面上的元素進(jìn)行截圖和對(duì)比,設(shè)定閾值進(jìn)行判斷。圖像相似度對(duì)比可分為傳統(tǒng)的基于像素差的方法和基于圖像特征的方法。圖像特征有SIFT、ORB等特征提取方法和深度學(xué)習(xí)方法。分別選擇一種代表性的算法進(jìn)行對(duì)比和測(cè)試。
- SSIM(結(jié)構(gòu)相似性指數(shù)),同時(shí)考慮圖片亮度、對(duì)比度與結(jié)構(gòu)信息。用于檢測(cè)兩張相同尺寸的圖像的相似性。對(duì)于圖像的亮度和對(duì)比度變化具有較好的魯棒性。
- SIFT(尺度不變特征變換),用于在圖像中檢測(cè)和描述局部特征,這些特征對(duì)圖像的縮放、旋轉(zhuǎn)和部分亮度變化具有不變性。能夠檢測(cè)和匹配不同尺寸下的特征,對(duì)圖像的仿射變換、噪聲和部分遮擋具有較好的魯棒性。
- LPIPS是一種深度學(xué)習(xí)特征,用于量化圖像之間的感知相似性,通過比較圖像在深度神經(jīng)網(wǎng)絡(luò)中的特征表示來(lái)工作,這些特征能夠捕捉到人類視覺所關(guān)注的圖像細(xì)節(jié)?;谝粋€(gè)大規(guī)模的人類感知相似性判斷數(shù)據(jù)集,包含484K個(gè)人類的判斷,涵蓋了多種圖像變換和失真類型。使用深度特征(例如VGG網(wǎng)絡(luò)中的特征)來(lái)衡量圖像相似性,比傳統(tǒng)的度量方法更有效。
v2 | v3 | SSIM | SIFT | LPIPS |
0.656 | 0.855 | 0.909 | ||
0.916 | 0.874 | 0.961 | ||
0.658 | 0.857 | 0.881 | ||
0.877 | 0.855 | 0.926 | ||
0.551 | 0.836 | 0.947 | ||
0.929 | 0.884 | 0.942 |
實(shí)驗(yàn)表明,基于深度學(xué)習(xí)特征的相似度對(duì)比結(jié)果更接近用戶感知,針對(duì)Vue2升級(jí)Vue3 UI組件庫(kù)導(dǎo)致的間距、字體、尺寸等細(xì)微差異判斷更準(zhǔn)確。因此采用LPIPS作為對(duì)比算法。
def compare_images(url1, url2):
loss_fn = lpips.LPIPS(net = 'alex')
img1 = cv2.imread(url1)
img2 = cv2.imread(url2)
if img1 is not None and img2 is not None and img1.size > 0 and img2.size > 0:
img2 = cv2.resize(img2, (img1.shape[1], img1.shape[0]))
cv2.imwrite(url2, img2)
combined_image = cv2.hconcat([img1, img2])
ex_img1 = lpips.im2tensor(lpips.load_image(url1))
ex_img2 = lpips.im2tensor(lpips.load_image(url2))
d = loss_fn.forward(ex_img1, ex_img2)
if d is not None:
cv2.putText(combined_image,'score: %.3f'%(1 - d.mean()), (20, 20), cv2.FONT_ITALIC, 0.4, (255, 0, 255))
return d, combined_image
else:
return None
依據(jù)業(yè)務(wù)流程,分別訪問新老路由,對(duì)頁(yè)面指定元素進(jìn)行截圖、對(duì)比和拼接,輸出測(cè)試截圖和完整的測(cè)試報(bào)告。
圖片
圖片
采用視覺輔助UI自動(dòng)化測(cè)試,更接近用戶真實(shí)感知,大大降低測(cè)試用例復(fù)雜度,提升測(cè)試效率,無(wú)需關(guān)注繁雜的DOM元素層級(jí)。
上線
以頁(yè)面配置的形式按待辦進(jìn)行灰度,跳轉(zhuǎn)至新路由。單元測(cè)試已集成至項(xiàng)目流水線中,MR和發(fā)布前觸發(fā)。灰度過程中手動(dòng)執(zhí)行E2E用例,以自定義環(huán)境變量的形式指定頁(yè)面路徑、元素選擇器、相似度閾值等。測(cè)試通過后修改頁(yè)面配置,引流至新路由。通過用戶故障群和頁(yè)面反饋入口響應(yīng)用戶反饋,結(jié)合前端埋點(diǎn)報(bào)表觀察線上使用情況和確定灰度策略。通過完善單元測(cè)試、E2E測(cè)試和制定合理的灰度策略,針對(duì)特定模板的遷移已順利完成,期間未收到線上故障反饋。
后續(xù)遷移策略將以老倉(cāng)庫(kù)中改動(dòng)較頻繁文件優(yōu)先入手,測(cè)試用例先行,借助自動(dòng)代碼轉(zhuǎn)換工具快速平穩(wěn)遷移,線上埋點(diǎn)數(shù)據(jù)做輔助。
收益
活躍代碼陸續(xù)遷移,結(jié)束多倉(cāng)庫(kù)并行,減少維護(hù)心智。之前我們的常態(tài)是新老倉(cāng)庫(kù)并行,開發(fā)一個(gè)完整的業(yè)務(wù)功能時(shí)要在ts和js,選項(xiàng)式API和setup語(yǔ)法之間頻繁切換,心智負(fù)擔(dān)較重?;钴S代碼陸續(xù)遷移至vue3新倉(cāng)庫(kù),結(jié)合新的框架特性和實(shí)用工具,能夠更專注于業(yè)務(wù)邏輯本身。
圖片
核心模塊重構(gòu),“巨石store”輕量化,提高可維護(hù)性和可演進(jìn)性,分層結(jié)構(gòu)保障核心邏輯穩(wěn)定性。原本配置能力擴(kuò)充時(shí)需要在復(fù)雜的數(shù)據(jù)流中“走迷宮”,耦合嚴(yán)重,通用代碼影響范圍大,常常出現(xiàn)A業(yè)務(wù)需求上線導(dǎo)致B業(yè)務(wù)不可用的情況。利用整潔架構(gòu)進(jìn)行分層設(shè)計(jì)后,新增一種審核模式僅需在適配器層新增對(duì)應(yīng)物料和action,用例層新增用例。無(wú)需修改實(shí)體層和其他業(yè)務(wù)相關(guān)的用例、通用頁(yè)面、物料等。
圖片
圖片
業(yè)務(wù)邏輯的重新梳理,彌補(bǔ)測(cè)試用例空缺。領(lǐng)域提取是對(duì)業(yè)務(wù)邏輯的重新梳理,前端能加深業(yè)務(wù)理解。穩(wěn)定性至上的模塊很長(zhǎng)時(shí)間缺少測(cè)試用例,造成對(duì)開發(fā)人員的經(jīng)驗(yàn)、能力依賴極大。完善核心鏈路的測(cè)試用例能有效降低回歸成本,保障系統(tǒng)穩(wěn)定性。
工具沉淀,組內(nèi)復(fù)用。自動(dòng)代碼轉(zhuǎn)換工具和基于AI能力的前端E2E測(cè)試方案為后續(xù)組內(nèi)其他項(xiàng)目的框架升級(jí)和遷移提供了便利。
總結(jié)與展望
在2023年開始漸進(jìn)式升級(jí)Vue3后,我們經(jīng)歷了很長(zhǎng)一段時(shí)間的多倉(cāng)庫(kù)并行。在新業(yè)務(wù)不斷接入、開發(fā)新成員加入的背景下,這樣的模式無(wú)疑提升了開發(fā)門檻和維護(hù)心智。本次活躍代碼的陸續(xù)遷移結(jié)束了多倉(cāng)庫(kù)并行的現(xiàn)狀,同時(shí)在整個(gè)實(shí)踐過程中我們?yōu)閷徍似脚_(tái)這個(gè)大型復(fù)雜項(xiàng)目前端引入了整潔架構(gòu)的思想,為后續(xù)的開發(fā)維護(hù)提供了一種新的思路。沉淀了一套自動(dòng)化vue2代碼轉(zhuǎn)vue3 setup的工具,可為后臺(tái)項(xiàng)目的框架升級(jí)提供便利。同時(shí)借助AI能力提升前端E2E測(cè)試的效率,利用計(jì)算機(jī)視覺輔助前端UI自動(dòng)化測(cè)試。有幾點(diǎn)心得:
完美重構(gòu)、敏捷重構(gòu)、系統(tǒng)穩(wěn)定性難以平衡。
圖片
既然下定決心對(duì)年久失修的代碼進(jìn)行重構(gòu),我們一定是追求極致優(yōu)化和整潔的。但是需求現(xiàn)狀是不斷有新特性進(jìn)來(lái),戰(zhàn)線拉的太長(zhǎng)必將導(dǎo)致抹平差異的成本增加,因此敏捷性也很重要。同時(shí)底線是關(guān)注系統(tǒng)的可靠性和穩(wěn)定性。這三者一定程度上存在矛盾,需平衡:
- 任務(wù)拆分,基于埋點(diǎn)數(shù)據(jù)選擇最重要或最緊急的鏈路進(jìn)行重構(gòu),而非追求大而全,先落地架構(gòu),后擴(kuò)充功能
- 測(cè)試用例先行,重構(gòu)必將引入風(fēng)險(xiǎn),用例先行能最大程度保障核心功能的穩(wěn)定遷移
- 制定合理的灰度策略,新增路由,以頁(yè)面配置形式進(jìn)行灰度,優(yōu)先uv較低的頁(yè)面驗(yàn)證,并保證及時(shí)的反饋渠道
- 持續(xù)優(yōu)化,重構(gòu)應(yīng)成為一種思想,在迭代中持續(xù)優(yōu)化
整潔架構(gòu)非銀彈,容易過度設(shè)計(jì),學(xué)習(xí)門檻較高。
- 按模塊重構(gòu),而非項(xiàng)目
- 針對(duì)新架構(gòu),制定長(zhǎng)期維護(hù)計(jì)劃,組織團(tuán)隊(duì)培訓(xùn),降低學(xué)習(xí)成本
多倉(cāng)庫(kù)遷移路線:數(shù)據(jù)為支撐,測(cè)試用例先行,借助自動(dòng)代碼轉(zhuǎn)換工具和視覺輔助UI自動(dòng)化測(cè)試,制定合理的灰度策略并建立及時(shí)的故障反饋和響應(yīng)渠道。對(duì)于大型復(fù)雜項(xiàng)目或模塊,先進(jìn)行面向遷移的重構(gòu),也能起到事半功倍的作用。
對(duì)我們而言,遷移的結(jié)束只是起點(diǎn),基于更整潔的架構(gòu)和更先進(jìn)的前端框架,未來(lái)仍有很多發(fā)力點(diǎn):
- 對(duì)于遷移過程中沉淀下來(lái)的新架構(gòu),需不斷優(yōu)化和改進(jìn),以適應(yīng)復(fù)雜的業(yè)務(wù)場(chǎng)景。
- 在更多的業(yè)務(wù)場(chǎng)景下評(píng)估遷移后的性能改進(jìn),確保用戶體驗(yàn)得到提升。
- 持續(xù)審查并解決在遷移過程中發(fā)現(xiàn)的技術(shù)債務(wù)。
- 持續(xù)建設(shè)自動(dòng)代碼轉(zhuǎn)換工具,賦能團(tuán)隊(duì)內(nèi)其他項(xiàng)目。
- 視覺輔助UI自動(dòng)化測(cè)試的方案,進(jìn)一步抽象,給到不熟悉自動(dòng)化測(cè)試的團(tuán)隊(duì)成員“開箱即用”。
- ...