我們是袋鼠雲數棧 UED 團隊,致力於打造優秀的一站式資料中臺產品。我們始終保持工匠精神,探索前端道路,為社群積累並傳播經驗價值。。
本文作者:琉易 liuxianyu.cn
前段時間分享了《搭建自動化 Web 頁面效能檢測系統 —— 設計篇》,我們提到了效能檢測的一些名詞和自研效能檢測系統的原因,也簡單介紹了一下系統的設計。在這裡我們書接上篇記錄下是如何實現一個效能檢測系統的。
開始前歡迎大家 Star:https://github.com/DTStack/yice-performance
先看下效能檢測系統 —— 易測的實現效果:
伺服器端框架選擇的是 Nestjs,Web 頁面選擇的是 Vite + React。由於所在團隊當前的研發環境已經全面接入自研的 devops 系統,易測收錄的頁面也是對接 devops 進行檢測的。
易測的檢測服務基於 Lighthouse + Puppeteer 實現。下圖是易測的一個整體架構圖:
易測的檢測流程是:根據子產品的版本獲取到待檢測的地址、對應的登入地址、使用者名稱和密碼,然後通過 Puppeteer 先跳轉到對應的登入頁面,接著由 Puppeteer 輸入使用者名稱、密碼、驗證碼,待登入完成後跳轉至待檢測的頁面,再進行頁面效能檢測。如果登入後還在登入頁,表示登入失敗,則獲取錯誤提示並丟擲到紀錄檔。為了檢測方便,檢測的均為開發環境且將登入的驗證碼校驗關閉。
以下是易測的檢測流程圖:
易測通過 Node 模組引入 Lighthouse,不需要登入的頁面檢測可以直接使用 Lighthouse,基礎用法:
const lighthouse = require('lighthouse');
const runResult = await lighthouse(url, lhOptions, lhConfig);
lhOptions
的主要引數有:
{
port: PORT, // chrome 執行的埠
logLevel: 'error',
output: 'html', // 以 html 檔案的方式輸出報告
onlyCategories: ['performance'], // 僅採集 performance 資料
disableStorageReset: true, // 禁止在執行前清除瀏覽器快取和其他儲存 API
}
lhConfig
的主要引數有:
{
extends: 'lighthouse:default', // 繼承預設設定
settings: {
onlyCategories: ['performance'],
// onlyAudits: ['first-contentful-paint'],
formFactor: 'desktop',
throttling: {
rttMs: 0, // 網路延遲,單位 ms
throughputKbps: 10 * 1024,
cpuSlowdownMultiplier: 1,
requestLatencyMs: 0, // 0 means unset
downloadThroughputKbps: 0,
uploadThroughputKbps: 0,
},
screenEmulation: {
mobile: false,
width: 1440,
height: 960,
deviceScaleFactor: 1,
disabled: false,
},
skipAudits: ['uses-http2'], // 跳過的檢查
emulatedUserAgent: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/98.0.4695.0 Safari/537.36 Chrome-Lighthouse',
},
}
settings
屬於 Lighthouse 的執行時設定,主要是用來模擬網路和裝置的資訊,以及使用到哪些審查器。如果檢測的頁面有 web 端和 h5 端之分,也是在 settings
進行設定。
檢測結果會有總分、各小項的耗時、瀑布圖、改進建議等,如下:
需要登入後才能存取的頁面涉及到登入、點選等操作,我們需要藉助 Puppeteer 來模擬點選。基礎用法:
const puppeteer = require('puppeteer');
const browser = await puppeteer.launch(puppeteerConfig);
const page = await browser.newPage();
{
args: ['--no-sandbox', '--disable-setuid-sandbox', `--remote-debugging-port=${PORT}`],
headless: true, // 是否使用無頭瀏覽器
defaultViewport: { width: 1440, height: 960 }, // 指定開啟頁面的寬高
slowMo: 15, // 使 Puppeteer 操作減速,可以觀察到 Puppeteer 的操作
}
當 headless
為 false 時方便本地偵錯,通過調整 slowMo
的大小可以觀察到 Puppeteer 的模擬操作。
const taskRun = async (task: ITask, successCallback, failCallback, completeCallback) => {
const { taskId, start, url, loginUrl } = task;
try {
// 依據是否包含 devops 來判斷是否需要登入
const needLogin = url.includes('devops') || loginUrl;
console.log(
`\ntaskId: ${taskId}, 本次檢測${needLogin ? '' : '不'}需要登入,檢測地址:`,
url
);
// 需要登入與否會決定使用哪個方法
const runResult = needLogin ? await withLogin(task) : await withOutLogin(task);
// 儲存檢測結果的報告檔案,便於預覽
const urlStr = url.replace(/http(s?):\/\//g, '').replace(/\/|#/g, '');
const fileName = `${moment().format('YYYY-MM-DD')}-${taskId}-${urlStr}`;
const filePath = `./static/${fileName}.html`;
const reportPath = `/report/${fileName}.html`;
fs.writeFileSync(filePath, runResult?.report);
// 整理效能資料
const audits = runResult?.lhr?.audits || {};
const auditRefs =
runResult?.lhr?.categories?.performance?.auditRefs?.filter((item) => item.weight) || [];
const { score = 0 } = runResult?.lhr?.categories?.performance || {};
const performance = [];
for (const auditRef of auditRefs) {
const { weight, acronym } = auditRef;
const { score, numericValue } = audits[auditRef.id] || {};
if (numericValue === undefined) {
throw new Error(
`檢測結果出現問題,沒有單項檢測時長,${JSON.stringify(audits[auditRef.id])}`
);
}
performance.push({
weight,
name: acronym,
score: Math.floor(score * 100),
duration: Math.round(numericValue * 100) / 100,
});
}
const duration = Number((new Date().getTime() - start).toFixed(2));
// 彙總檢測結果
const result = {
score: Math.floor(score * 100),
duration,
reportPath,
performance,
};
// 丟擲結果
await successCallback(taskId, result);
console.log(`taskId: ${taskId}, 本次檢測耗時:${duration}ms`);
return result;
} catch (error) {
// 錯誤處理
const failReason = error.toString().substring(0, 10240);
const duration = Number((new Date().getTime() - start).toFixed(2));
await failCallback(task, failReason, duration);
console.error(`taskId: ${taskId}, taskRun error`, `taskRun error, ${failReason}`);
throw error;
} finally {
completeCallback();
}
};
const withOutLogin = async (runInfo: ITask) => {
const { taskId, url } = runInfo;
let chrome, runResult;
try {
console.log(`taskId: ${taskId}, 開始檢測`);
// 通過 API 控制 Node 端的 chrome 開啟分頁,藉助 Lighthouse 檢測頁面
chrome = await chromeLauncher.launch(chromeLauncherOptions);
runResult = await lighthouse(url, getLhOptions(chrome.port), lhConfig);
console.log(`taskId: ${taskId}, 檢測完成,開始整理資料`);
} catch (error) {
console.error(`taskId: ${taskId}, 檢測失敗`, `檢測失敗,${error?.toString()}`);
throw error;
} finally {
await chrome.kill();
}
return runResult;
};
const withLogin = async (runInfo: ITask) => {
const { taskId, url } = runInfo;
// 建立 puppeteer 無頭瀏覽器
const browser = await puppeteer.launch(getPuppeteerConfig(PORT));
const page = await browser.newPage();
let runResult;
try {
// 登入
await toLogin(page, runInfo);
// 選擇租戶
await changeTenant(page, taskId);
console.log(`taskId: ${taskId}, 準備工作完成,開始檢測`);
// 開始檢測
runResult = await lighthouse(url, getLhOptions(PORT), lhConfig);
console.log(`taskId: ${taskId}, 檢測完成,開始整理資料`);
} catch (error) {
console.error(`taskId: ${taskId}, 檢測出錯`, `${error?.toString()}`);
throw error;
} finally {
// 檢測結束關閉分頁、無頭瀏覽器
await page.close();
await browser.close();
}
return runResult;
};
所在團隊的子產品均需要登入後才能存取,且每次檢測開啟的都是類似無痕瀏覽器的分頁,不存在登入資訊的快取,所以每次檢測這些頁面前需要完成登入操作:
const toLogin = async (page, runInfo: ITask) => {
const { taskId, loginUrl, username, password } = runInfo;
try {
await page.goto(loginUrl);
// 等待指定的選擇器匹配元素出現在頁面中
await page.waitForSelector('#username', { visible: true });
// 使用者名稱、密碼、驗證碼
const usernameInput = await page.$('#username');
await usernameInput.type(username);
const passwordInput = await page.$('#password');
await passwordInput.type(password);
const codeInput = await page.$('.c-login__container__form__code__input');
await codeInput.type('bz4x');
// 登入按鈕
await page.click('.c-login__container__form__btn');
// await page.waitForNavigation();
await sleep(Number(process.env.RESPONSE_SLEEP || 0) * 2);
const currentUrl = await page.url();
// 依據是否包含 login 來判斷是否需要登入,若跳轉之後仍在登入頁,說明登入出錯
if (currentUrl.includes('login')) {
throw new Error(`taskId: ${taskId}, 登入失敗,仍在登入頁面`);
} else {
console.log(`taskId: ${taskId}, 登入成功`);
}
} catch (error) {
console.error(`taskId: ${taskId}, 登入出錯`, error?.toString());
throw error;
}
};
等待所有的檢測步驟都完成後,在 successCallback
方法中處理檢測資料,此時可根據不同的效能指標計算得出最終得分和小項得分,統一落庫。
除了可以在頁面手動觸發檢測,易測主要使用的是自動檢測。自動檢測的目的是方便統計所有子產品的效能趨勢,便於分析各版本間的效能變化,以及子產品間的效能優劣,最終得出優化方向。
易測試執行階段,由於使用的是開發環境進行檢測,所以將自動檢測時間設定為工作時間的間隙,減少影響檢測結果的干擾因素,後續正式部署後,也將調低檢測的頻率。
自動檢測可以主動進行任務的排程,也可以手動觸發任務,藉助 @nestjs/schedule
實現定時任務:
import { Cron } from '@nestjs/schedule';
export class TaskRunService {
// 每分鐘執行一次 https://docs.nestjs.com/techniques/task-scheduling#declarative-cron-jobs
@Cron('0 * * * * *')
async handleCron() {
// 檢測版本的 cron 符合當前時間執行的則建立任務
process.env.NODE_ENV === 'production' && this.checkCronForCurrentDate();
}
}
檢測失敗會有釘釘通知,點選可快速跳轉至易測內檢視具體原因。
由下方的趨勢圖簡單分析後,可以得出子產品版本間的效能變化。
所在團隊的子產品在版本間做了一些腳手架的封裝升級,對接 Jenkins 就可以採集到各個版本間構建時長和構建後的檔案大小等資訊的變化,有助於效能相關資料的彙總、腳手架的分析改進。
在 Jenkins 的構建回撥裡,處理後可以拿到構建時長和構建後的檔案大小等資訊,由 Jenkins 呼叫易測提供的介面,按分支處理好版本後將資料落庫,在易測中展示出來。
如果你也準備搭建一個自己團隊的檢測系統,可以參考下易測的設計思路,希望這兩篇文章對你的工作有所助力。
完成上述工作後,接下來需要考慮的有易測功能的許可權控制、資料分析、如何根據業務場景進行檢測等方面。畢竟 Lighthouse 檢測的一般是單個頁面,而業務場景一般是工作流程的編排即流程的整體操作。
最後,歡迎大家不吝 Star:https://github.com/DTStack/yice-performance
歡迎關注【袋鼠雲數棧UED團隊】~
袋鼠雲數棧UED團隊持續為廣大開發者分享技術成果,相繼參與開源了歡迎star