一、背景
体验是得物的业务关键词之一,对于前端开发而言,提高用户体验更是重要工作内容之一。
得物前端平台目前有巡检系统、监控平台等多种手段保障线上页面稳定运行,但是仍有一部分问题处于“监控死角”,而且巡检、监控都属于后置告警手段,为了确保页面上线前就能得到一定的用户体验保障,结合公司的战略目标,我们决定开发一个H5页面检测服务,用来前置检测即将上线的页面,提前暴露该页面可能存在的问题反馈给对应的开发/运营,我们将这个服务称之为:“体验卡口”。
本文从这次“体验卡口”服务的开发实践出发,同时介绍得物巡检系统的架构和设计,希望能给参与稳定性建设的开发小伙伴提供一定的学习和参考价值。
二、用户体验量化标准
当我们试图量化影响用户体验的问题时,需要思考以下两个主要问题:
什么影响了用户体验?
我们已经通过丰富的数据支撑和实践经验,对影响用户体验的因素有了深入了解。从过去的线上问题反馈收集和开发经验中,我们将体验问题大致分为两个等级:
- P0级:这些问题严重影响页面加载速度或涉及到安全风险,例如页面包含超大的图片/媒体资源、页面中含有个人隐私信息;
- P1级:这些问题可能对用户体验造成潜在影响,例如页面中存在响应时间超过300ms的接口请求。
如何检测以及量化问题?
一旦我们对体验问题进行了定义和分级,接下来需要建立适当的机制来检测这些问题。对于卡口服务,我们可以采取以下步骤来量化问题、转换为可执行的检测代码,并通过卡口服务生成相应的检测报告供调用方使用:
- 确定指标和标准:首先,我们需要确定用于量化体验问题的指标和标准。例如,对于接口请求速度问题,可以使用接口响应时间作为指标,同时设定一定的标准,例如超过特定时间阈值即视为问题。
- 编写自动化脚本:基于指标和标准,我们可以编写自动化脚本来模拟用户在无头浏览器中执行相关操作,例如加载页面、点击按钮、发送请求等。这些脚本将根据设定的指标进行性能测量和问题检测。
- 使用无头浏览器执行测试:我们可以在无头浏览器中运行自动化脚本,模拟用户行为并收集相应的性能数据。
- 结果分析和报告生成:通过收集的性能数据,我们可以进行结果分析,并将问题和相关数据转化为检测报告。该报告可以包括问题的详细描述、问题等级、相关性能指标和数据。
- 提供给调用方:最后,通过卡口服务,我们可以将生成的检测报告提供给调用方。调用方可以根据报告中的问题和数据进行相应的优化和改进,以提升用户体验。
这样的机制可以帮助我们自动化地检测和量化体验问题,并提供可执行的检测代码和相关报告。这样一来,我们可以更有效地识别和解决问题,并提供准确的数据给予开发团队进行优化。
以下是我们整理出需要具体实现的检测case:
收集完具体的影响用户体验的case之后,要确定具体的开发方案,由于卡口服务与得物前端平台巡检系统有很多技术实现重合的部分,所以我们决定利用现有的巡检架构,将“体验卡口”集成到现有的巡检系统中,可以节省大量的开发时间。
3、巡检系统基础架构
巡检系统的程序目标一句话总结:定时从数据源获取待检测页面地址列表,然后进行批量检测并生成报告。
为了应对不同场景下的个性化需求,巡检系统抽象出了三个巡检器基类,各场景继承基类实现定制需求。
3.1 巡检器基类
- DataProviderBase(数据提供基类):
- dataSlim(): 简化冗余数据;
- fetchData():获取远程数据,处理并返回待检测页面url列表;
- isSkipTime():用来设置条件,在某些特定条件下跳过定时任务;
- schedule():设置定时任务运行区间;
- PageInspectorBase(页面检查器基类):
- check():检查器入口,用来打开指定的检测页面,并初始化各种资源的监听;
- injectRequestHeaders():注入页面接口请求需要的cookie、token等;
- urlCheck():url地址检查;
- onRequest():监听页面请求;
- onResponse():监听页面响应;
- onPageError():监听页面错误;
- DataReporterBase(数据报告基类):
- buildReporter(): 根据采集到的错误信息生成检测报告;
- feishuNotify():将生成的报告通过飞书发送到指定的通知群;
- getHTMLReporterUrl():根据ejs模板将报告生成html静态文件并上传,返回在线报告地址;
我们可以形象地将这三个基类比成一家饭店的三个不同分工的部门,能更方便地去理解它:
饭店前台负责接收顾客提供的订单,后厨根据订单下料炒菜装盘,服务员将做好的饭菜提供给顾客。
DataProviderBase(数据提供基类):负责定时轮询接收外部提供的待检测页面列表。这个组件类似于饭店前台,接收顾客提供的订单。它负责从外部获取待检测的页面列表,并将这些页面传递给检测器进行检测。
PageInspectorBase(页面检查器基类):逐一检测页面列表中的每一个URL,并检测页面中的潜在问题。类似于后厨根据订单下料、炒菜和装盘的过程,这个组件负责逐个检测待检测页面列表中的URL,并对每个页面进行问题检测。它可以使用一系列的检测方法和规则,以确定页面是否存在潜在问题。
DataReporterBase(数据报告基类):将检测搜集的问题进一步整理后发送报告。类似于服务员将做好的饭菜提供给顾客,这个组件负责将经过检测的问题进行整理和汇总,并生成相应的报告。报告可以包括问题的描述、严重程度、相关页面URL等信息。然后,报告可以被发送给相关的利益相关者,例如开发或运营。
3.2 巡检器
基于以上三个基类,根据不同巡检场景开发不同的巡检器(inspector),每一个巡检器都包含了分别继承以上三个基类的三个子类,继承了基类的子类巡检器通过覆写/拓展基类方法以实现自己的个性化需求,以下是一个极简的巡检器例子:
// data-provider.ts
export class DataProvider extends DataProviderBase {
// 实现特定的页面列表获取逻辑
async fetchData(args) {
return await axios.get('https://xxx.xxx').then(res => res.data.urlList)
}
// 每隔15分钟获取一次待检测列表
async schedule() {
return [{cron: '*/15 * * * *',args: {}}]
}
}
// page-inspector.ts
export class PageInspector extends PageInspectorBase {
async onPageOpen(page, reporter: PageReporter, data) {
const pageTitle = await page.evaluate('window.document.title')
console.log('这里可以获取到页面title', pageTitle)
}
}
// data-reporter.ts
export class DataReporter extends DataReporterBase {
async beforeFeishuNotify(data: InspectorReportBase) {
console.log('在飞书通知前做点什么', data)
return data
}
}
3.3 巡检主程序
在巡检系统中,每个页面的检测任务都是独立的异步任务,并且每份检测报告的整理和发送也是独立的异步任务。为了方便管理和维护这些异步任务以及任务消息的存储和传递,巡检系统使用Redis结合Bull作为巡检系统的异步任务管理工具。
Redis是一个内存数据库,它提供高性能的数据存储和访问能力。
Bull是一个基于Redis的任务队列库,它提供了任务的调度、执行和消息传递的功能。
有了巡检器和异步任务管理能力,主程序的主要工作如下:
- 定义任务:使用Bull创建两个任务队列,page_queue用于存放“页面检测任务”,reporter_queue用于存放“报告生成任务”。
- 生产任务:在巡检系统中,页面检测任务和报告生成任务的生产者(主程序)负责将任务添加到相应的队列中。当巡检器(inspector)需要进行页面检测时,生产者将页面检测任务加入page_queue;当需要生成报告时,生产者将报告生成任务加入reporter_queue。
- 消费任务:巡检系统中的任务消费者(主程序)负责从任务队列中获取任务并执行,一次检测任务会有>=1个页面检测任务,交由上文介绍的页面检查器PageInspector执行页面检查,然后将检测报告存储到Redis中,当该次检测任务的所有页面都完成检测后,reporter_queue任务被创建并交由巡检器(inspector)的DataReporter消费。
四、卡口服务
介绍完巡检系统,接下来我们看如何将卡口服务集成自巡检系统中。
卡口服务的主要功能用一句话概括:接入巡检系统的现有架构,对外暴露一个远程接口,提供给接口调用方主动检测页面的能力,然后将检测报告回传给调用方。
对比现有巡检系统与卡口服务的差异:
检测发起方 | 检测case | 检测报告重点 | 检测回调 | 在线报告 | |
巡检系统 | 系统自行定时触发 | 通用检测 | 重点关注异常 | 无 | 有 |
卡口服务 | 接口调用触发 | 基于通用检测拓展 | 给出详细报告 | 根据需求触发 | 有 |
从上文的巡检系统架构介绍以及分析上面的表格可知,卡口服务的开发工作就是基于巡检系统的巡检器架构去定制实现一个巡检器。
4.1 卡口服务运行时序
开始开发卡口服务的巡检器之前,我们先梳理一下整个卡口服务的运行时序:
其中卡口服务主要开发任务:步骤2、3、4、7。
4.2 创建任务接口
我们在上文提到,巡检是一种后置检测手段,所以巡检系统的DataProviderBase(数据提供基类)主要能力是:“定时轮询接收外部提供的待检测页面列表”。
对于卡口服务来说,检测任务由检测方主动创建,所以我们不需要过多关注DataProviderBase的实现,而是要启动一个api服务,负责创建检测任务,示例代码如下:
app.post('/xxx.xxx', async (req, res) => {
const urls = req.body?.urls // 待检测url列表
const callBack = req.body?.callBack // 调用方接收报告的回调接口地址
const transData = req.body?.transData // 调用方需要在回调中拿到的透传数据
// 巡检系统检测任务创建函数
newApp.createJob(urls.map(url => ({ url,
// 在redis任务队列中传递的信息
pos: { callBack, transData },
})),
jobId => { // 返回任务id给调用方
res.json({ taskId: jobId })
}
)
})
4.3 页面检测
PageInspectorBase(页面检查器基类)是卡口服务的改造重点,在这个基类的子类实现方面,我们需要去做前文提到的具体待实现的检测case,主要有两类检测case:
- 请求资源型检测case:在子类中覆写onResponse方法,针对不同的资源类型执行不同的检测逻辑;
- 运行时检测case:在子类中覆写onPageOpen方法,通过基类传入的Page对象,注入js脚本,执行页面运行时检测;
// 页面检测类
class PageInspector extends PageInspectorBase {
// ...
// 针对不同资源类型检测方法配置Map
checkResponseMethodsMap = new Map([['image', this.checkImageResponse]])
// 请求资源型检测入口 针对请求资源进行检测
async onResponse(response: Response, reporter: PageReporter, data: IJobItem) {
const resourceType = response.request().resourceType()
const checkMethod = this.checkResponseMethodsMap.get(resourceType)
await checkMethod(response, reporter, data)
}
// 检测图片资源
async checkImageResponse(response: Response, reporter: PageReporter, data: IJobItem) {
// ...
if (imageCdnList.includes(url)) {reporter.add({ errorType: "图片类型错误.非cdn资源" })}
// ...
}
// 运行时检测入口 在页面打开时执行注入的js脚本进行运行时检测
async onPageOpen(page, reporter: PageReporter, data) {
// ...
const htmlText = await page.evaluate('window.document.documentElement.innerHTML')
const phoneRegex = /\b((?:\+?86)?1(?:3\d{3}|5[^4\D]\d{2}|8\d{3}|7(?:[35678]\d{2}|4(?:0\d|1[0-2]|9\d))|9[189]\d{2}|66\d{2})\d{6})\b/g;
let phoneMatch: RegExpExecArray
let collectMessage = []
while ((phoneMatch = phoneRegex.exec(html)) !== null) {
const phone = phoneMatch[1];collectMessage.push(`手机号码:${phone}`);
}
collectMessage.forEach(val => {reporter.add({ errorMessage: `敏感信息:${val}`})})
// ...
}
// ...
}
RegExp.prototype.exec()
在设置了 global 或 sticky 标志位的情况下(如 /foo/g 或 /foo/y),JavaScript RegExp 对象是有状态的。它们会将上次成功匹配后的位置记录在 lastIndex 属性中。使用此特性,exec() 可用来对单个字符串中的多次匹配结果进行逐条的遍历(包括捕获到的匹配),而相比之下, String.prototype.match() 只会返回匹配到的结果。
4.4 报告与回调
检测任务执行完毕后,reporter_queue中会被创建一个新的“报告生成任务”,主程序调用继承了DataReporterBase的子类进行以下操作:
- 对检测项逐一整理,将搜集到的错误进行等级分类,整理出报告源数据;
- 根据报告源数据结合ejs模板生成静态html并上传,得到在线检测报告地址;
- 向调用方回调检测报告。
4.4.1 利用ChatGPT生成报告模板
大部分研发的UI设计水平有目共睹,与其死磕报告模板的样式和交互,不如交给AI去搞定,输入预先定义好的数据结构和要求,ChatGPT能非常快速地给到一个模板,然后只需要稍微做一点点调整就能达到可用的状态。
Q: 我需要你写一个ejs模板,这个模板用来展示一个统计报告,为了样式和交互方便,模板会通过内置bootstrap的css样式和Jquery,你可以用到他们来实现html元素的组合和设置交互,具体的数据结构如下:
interface CheckResult {
name: string;
status: 0 | 1;
message: string;
severityLevel: 0 | 1;
}
interface DetectionResult {
url: string;
status: 0 | 1;
checkResults: CheckResult[];
}
interface InspectorReport {
taskId: string;
reporterUrl?: string;
detectionResults: DetectionResult[];
startTimeStr: string;
endTimeStr: string;
duration: number;
pageTotal: number;
taskStat: {
completed: number,
waiting: number,
active: number,
delayed: number,
paused: number,
failed: number,
}
}
export interface InspectorReportBase {
manifest: IManifest,
data: InspectorReport;
}
export interface IManifest {
name: string
desc?: string
notifyTo?: string[]
}
A: 好的,以下是基于提供的数据结构设计的ejs模板:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<link href="https://xxx.xxx.css" rel="stylesheet">
<script src="https://xxx.xxx.js"></script>
<title>
<%=data.manifest.desc%>
</title>
</head>
<body>
<div class="container">
<h2 class="text-center">
<%= data.manifest.desc %> 统计报告
</h2>
<div class="row">
<div class="col-md-4">
<div class="card">
<div class="card-body">
<h5 class="card-title">任务信息</h5>
<p class="card-text">任务ID: <%= data.data.taskId %>
</p>
<p class="card-text">检测开始时间: <%= data.data.startTimeStr %>
</p>
<p class="card-text">检测结束时间: <%= data.data.endTimeStr %>
</p>
<p class="card-text">检测时长: <%= data.data.duration %> 秒</p>
<p class="card-text">检测页面数: <%= data.data.pageTotal %>
</p>
<!--以下省略 -->
</body>
</html>
AI生成的在线报告模板:
4.4.2 与调用方解耦
作为服务提供者,卡口服务并不想过多关注调用者是谁,那么要如何实现与调用方的解耦?
——回顾上文“创建任务接口”环节,这个接口设计的入参有callBack和transData字段:
- callBack:检测任务结束时,卡口服务将调用该地址回传检测报告;
- transData:检测任务结束时,需要透传给callBack的数据;
页面检测任务完成后,在回调测试报告环节,卡口服务将从redis队列任务的缓存中中取出这两个值,使用POST请求将报告和transData发送给callBack。
// 卡口服务回调示例代码
axios.post(callBack, {
data: { msg: "本次检测检测报告如下:xxxxx", transData: `透传的数据如下:${transData}` }
})
在后续的规划中,为了使卡口服务能适应更多场景的不同需求,参考后端微服务注册中心的概念,可以实现一个简易的注册中心的抽象模型,进一步解耦卡口服务与其调用方之间的逻辑,同时能拓展更多功能:自定义检测项、自定义报告模板等。
5、总结
对于卡口服务来说,学习和阅读巡检的源码是一个重要的前置工作。通过深入理解巡检系统的实现细节和底层架构设计可以更好地理解巡检系统是如何工作的,从而更好地进行定制和扩展,这些经验也帮助提升了自己的编码能力和设计能力,在后续的技术项目中可以得到应用和实践。希望阅读完本文的开发同学都能从本篇实践总结中有所收获~
引用/参考链接
GitHub - OptimalBits/bull
RegExp.prototype.exec() - JavaScript | MDN