09 接口指标:监听接口状态,收集页面接口异常数据
你好,我是三桥。
很多前端同学并不太重视接口异常情况的维护,反而把精力集中在当前的功能是否可用。但在实际的应用过程中,可能会出现各种不可预知的接口异常情况。如果没有做好充分的异常处理和维护工作,不仅会导致较差的用户体验,甚至影响整个Web应用的稳定性和可靠性。
因此,这节课我们会把目光放在第5节课提到的接口异常这个数据指标,重点学习在前端全链路中捕获请求异常的方法。相信通过这节课的学习,你就能掌握前端全链路的异常处理方案,同时还进一步提升你的前端技能和发现问题的能力。
请求接口封装的常见误区
我们知道,前端有两种向服务器发起请求的方法。一种是使用XMLHTTPRequest对象,是早期浏览器的实现方案。另一种是使用Fetch API,这个是ES6新增的特性。这节课,我选用的是Fetch来带你分析请求异常捕获的一系列问题。
我们先来看看两个常见的例子。
第一个例子,有些同学懂得Fetch是异步函数,并且有意识地在代码里添加了trycatch捕获异常,这样,在发生请求异常的时候,就能通过Catch捕获异常了。我们也能进行异常后的交互处理,比如提供信息来提醒用户。
let result;
try {
result = await fetch(url, {
body,
method: 'POST',
keepalive: true
});
} catch (e) {
console.log(e)
}
// 下面继续使用result结果处理业务
上述代码的问题在于,这里的catch逻辑被忽略了。我阅读过许多前端项目代码,几乎所有catch都是直接使用console.log输出错误信息的。
这样的日志输出有用吗?有用的,但它可能只在开发环境中有效,在生产环境中几乎没用。因为日志是在用户的浏览器上输出的,出现问题时,前端同学根本无法获取这些日志。
第二个例子,有些前端同学会使用二次封装的方案,对Fetch函数进行封装,然后在封装代码里添加逻辑。如下面的封装代码所示。
// fetchUtil.ts
export async function fetchUtil(url: string, body: string) {
// @ts-ignore
return fetch(url, {
body,
method: 'POST',
keepalive: true
});
}
// index.ts
import { fetchUtil } from './fetchutil'
let result = ;
try {
result = await fetchUtil(url, JSON.stringify(body));
} catch (e) {
console.log(e)
}
这种方式虽然可行,但还是会存在业务耦合的情况。大部分前端同学对函数的封装理解都是新建一个函数,然后在函数内添加通用逻辑。
如果你只维护一个项目,这种理解是没问题的。但是,如果你需要维护10个以上的项目,这种代码的封装模式就会存在许多缺陷。比如你有多个不同架构的前端项目,那就要给每一个项目都重新封装一遍,这也会增加公共代码的维护成本。
此外,有时候我们会为了公共业务逻辑,将相关的参数封装后再发起请求,比如在发起请求之前带上accessToken。如果我们还需要收集请求前后的各种参数,那就意味着耦合了公共业务逻辑和全链路逻辑。
两个典型的问题说完了,现在轮到咱们思考解决方案了。我们该怎么在监听请求异常的同时,让前端全链路既能解耦又具备扩展性呢?
监听请求目的是什么?
我们已经探讨过,前端全链路其中的一个需求是要能够追踪和溯源前后端链路日志,并且每一个前端请求跟后端接收的请求能够一一对应。
有了这个基础,我们先理解监听请求的几个核心需求。
首先,我们需要监控前端发起请求的稳定性,例如请求是否已经到达服务端,用户是否中断请求以及请求是否失败等。
其次,我们需要关注用户的网络状况,例如用户是否因为网络问题影响了使用体验,例如是否是国外用户访问国内服务器。
第三,现在许多接口功能具备登录鉴权和用户画像等特性,几乎每个用户都有其独特的数据或者分组,所以我们还需要判断接口返回的信息是否合理。
总结一下,我们监听请求的核心目的,就是发现请求异常的状态。不仅要保证在改造过程中不耦合业务逻辑,还要支持更多前端项目的前端全链路。这就引出了我们设计的两个原则,支持监听Fetch,同时考虑向下兼容支持XMLHttpRequest。
首先,我们遵循不耦合业务的原则。这个道理很简单,我们设计的全链路方案并不是为了某个具体项目,而是一套通用性的方案,因此我们不应将其与业务紧密耦合。所以,我们选择对XMLHttpRequest对象和Fetch API方法进行适当的改造,拦截每一次请求,并在请求前后做出相应的处理。
第二个原则是向下兼容。由于用户的环境复杂,特别是浏览器版本的很多,Fetch特性虽然提供了更好的开发体验,但不能支持一些旧版本的浏览器。而XMLHttpRequest对象的兼容性更好,因此,为了确保全链路监控的覆盖面更广,我们需要同时支持XMLHttpRequest和Fetch两种请求方式。
我们应该关注哪些请求异常?
从技术层面来看,发起一次请求时,我们需要关注的包括:请求的HTTP状态码是否正常?请求是否超时?请求是否被中断?
通过设计一套合理的监听机制来关注这些状态,我们可以更好地了解用户的实际网络状态,从而提升产品的使用体验。
HTTP状态码
首先,我们来理解一下HTTP状态码,它由特定的三位数字组成,表示服务器对客户端(通常是浏览器或App)的请求响应结果,比如请求是否成功,是否出现错误等等。
咱们比较熟悉的几个,200表示请求成功、301表示永久重定向、400表示无效请求、404表示资源未找到、500表示服务器错误等。
虽然所有的状态码都值得关注,但在代码实现层面,我们应该区分三种情况:成功响应、信息响应和重定向响应,以及错误响应。错误响应又可以细分为客户端错误和服务端错误。
成功响应属于2xx范围,表示响应的结果都是正常的。除非有特殊需求需要监控这么细的维度,否则我们一般不会记录这些正常结果。通常,这类日志会被记录为普通日志类型 Info
。
信息响应属于1xx范围,重定向响应属于3xx范围,这类的状态码主要表示一些特殊的响应状态,原则上并不代表存在问题,如果出现这些状态码,我们可以给它定义为待关注的日志,例如 warn
类型日志。
错误响应属于4xx和5xx范围。这类状态码说明存在网络问题或者服务问题,属于严重级别,我们可以在日志中使用 error
、hight
以及 critical
等类型。
最终,我们可以得到如下评估请求响应结果的日志级别的方法。
export const getFetchStatusLevel = (status: number): TraceDataSeverity => {
if (status >= 500) {
return TraceDataSeverity.Critical
} else if (status >= 400) {
return TraceDataSeverity.Error
} else if (status >= 300) {
return TraceDataSeverity.Warning
} else if (status >= 200) {
return TraceDataSeverity.Info
} else {
return TraceDataSeverity.Else
}
}
请求中断
我们可能会忽视一种情况,即当用户在浏览器发起请求后,由于异步原因,在结果尚未返回时受各种外界的因素影响,请求被动地终止了。
在浏览器端发生的这种被动式终止请求,服务器是不会返回响应结果和状态码的,因此我们无法通过服务器响应结果获取终止状态。
那么,我们如何通过代码发现请求被中断呢?
在XMLHTTPRequest对象中,abort方法可以捕获被中断的请求,但在本课程中我们不会重点探讨这种场景。有兴趣的同学可以自行深入学习。
我们以Fetch的特性为例。可惜的是,Fetch对象默认没有事件终止请求,需要配合AbortController对象才能实现。
例如以下代码,通过catch捕获,然后通过err对象的name属性就能判断是否是AbortError类型,就能发现请求是否被终止了。
try {
res = await fetch(url, options);
} catch (err) {
if (err.name == 'AbortError') {
// 发现被中止请求后,处理的逻辑
} else {
throw err
}
}
请求超时
我们经常遇到这样的情况:用户反馈打开的页面没内容,或者页面显示不正常。通常我们的解决办法是建议用户刷新页面。
这种情况大概率是用户的网络问题导致的,使得他们在那一刻无法正常使用Web页面。
那么,前端同学可能就会问了,既然让用户刷新就能解决问题,我们为什么还要关心用户网络问题?
确实,刷新页面可以解决问题,但我曾经遇到一个更有趣的超时问题。
有一次我们收到一个用户反馈,说产品的其中一个功能总是无法使用。我们调查了服务器日志、用户的来源以及用户的操作记录。然后我们与运维同事一起检查日志,最终发现这个地区来自国外的一个海岛小国家,刚好他们的网络拒绝了我们某一类后端服务的请求。
从这个特殊问题来看,如果我们能做好用户请求超时的监控,可能就能提前发现服务被拦截的问题,而不必等到用户反馈后才去修复。
虽然请求超时并不常见,但通过监听可以提前发现这种小概率的问题。那么,我们应该如何实现请求超时的监听呢?
我们以Fetch特性为例,像请求中止一样,Fetch特性并未提供超时机制和超时事件的实现。我们可以利用AbortController对象的功能,结合Promise来实现超时中止的请求。
const controller = new AbortController()
const { signal } = controller
const handleTimeout = (delay = 6000) => {
return new Promise(_, reject) => {
setTimeout(() => {
// 触发中止
controller.abort()
reject(new Error('TimeoutAbortError'))
}, delay)
})
}
// 处理请求超时
Promise.race([
handleTimeout(),
fetch(url, {
signal
})
]).catch(err => {
if (err.name === 'TimeoutAbortError') {
// 请求超时
}
})
如何做到快速支持多项目?
前面我们学习了全链路中需要重点关注的三种接口请求状态,并展示了简要的代码示例。但这些代码并不能直接应用到项目上,特别是要快速支持多种不同架构的前端项目的时候,就需要采用通用前端插件的方式。
我们还是以Fetch对象为例,在不耦合业务代码的情况下,学习如何实现对Fetch的二次封装。
Fetch API是Windows的一个属性,我们可以通过window.fetch获取该对象。同时,我们也可以自定义Fetch扩展并覆盖它,例如下面代码。
// src/core/fetch.ts
const { fetch: originFetch } = window;
const interceptFetch = () => {
return async (...args: any) => {
const [ url, options ] = args;
let res = null;
try {
res = await originFetch(url, options);
} catch (err) {
console.log(err)
}
return res
}
}
export default interceptFetch
在上述代码的实现方案中,我们通过window获取原生Fetch对象,并将其重新命名为originFetch,然后定义了新的函数interceptFetch,这样,函数内部就会直接返回已使用的Fetch对象。
经过这样的代码设计,我们就能够实现Fetch对象的拦截,然后就能基于interceptFetch函数做一些逻辑上的微改造。
由于我们需要在不耦合代码的前提下自动捕获HTTP状态码,所以,基于现有代码,结合前端全链路的数据指标设计方案,我们将增加三个逻辑。
首先,由于我们只关心非正常状态,所以我们在拦截器里只关注非2xx的状态码。
其次,我们将在interceptFetch函数中新增onerror函数参数,以便在请求异常时为外层提供自定义逻辑参数。
最后,在拦截器内增加请求耗时,同时以elapsedTime参数传入onerror函数。
具体的代码实现逻辑,参考如下。
import { getTimestamp } from "./util";
const { fetch: originFetch } = window;
export type OnFetchError = {
url: string
status: number
statusText: string
method: 'POST' | 'GET'
body: any,
elapsedTime: number
}
export type InterceptFetchType = {
onError: (error: OnFetchError) => void;
}
// 拦截fetch
const interceptFetch = ({ onError } : InterceptFetchType) => {
return async (...args: any) => {
const [url, options] = args;
const startTime = getTimestamp()
let res;
try {
res = await originFetch(url, options);
} catch (err) {
}
if (!(res.ok && res.status >= 200 && res.status < 300)) {
onError({
url,
status: res.status,
statusText: res.statusText,
method: options.method,
body: options.body,
elapsedTime: getTimestamp() - startTime,
})
}
return res
}
}
export default interceptFetch
完成上述fetch拦截器模块后,我们就能为前端项目提供一种快速、高效的方式来引入这个fetch拦截器。例如以下的示例代码。
// src/index.ts
import interceptFetch from './core/fetch'
window.fetch = interceptFetch({
onError: (error) => {
console.log(error)
}
})
通过上述的实现方案,我们就实现了一个简单的fetch拦截器,它能够全局监听接口请求异常,并把异常信息提供给onError函数。
当然,这只是一个基础版本,实际使用中我们可能需要对其进行进一步的扩展和优化,满足更复杂的业务需求。
总结
本节课重点学习了前端全链路监控中的接口异常问题,我们需要特别关注HTTP状态码、请求超时和请求中断。这三种状态是前端开发中常遇到和需要处理的问题,我们需要设计出一套合理的监听机制来关注这些状态,从而更好地了解用户的实际网络状态,提升产品的使用体验。
在设计监听机制时,应遵循不耦合业务和向下兼容的原则。不耦合业务是为了让我们设计的全链路方案具有通用性,而不是仅针对某个具体项目。向下兼容则是为了确保全链路监控的覆盖面更广,支持XMLHttpRequest和Fetch两种请求方式。
基于两个原则,我们的方案选择了拦截和改造Fetch API或XMLHttpRequest对象的方法,实现全局的接口请求监听。最后,我还提供了一个基于FetchAPI的拦截器示例,让你能够更深入地理解实现的原理。
下节课,我们将继续学习捕获前端资源和脚本错误异常信息的方法。
思考题
本接我们重点讲述了以Fetch对象为基础的请求接口拦截器的实现方案,并没有重点探讨XMLHttpRequest对象的方案。
现在就留个作业给你,你能尝试一下基于这节课提到的方案和原则,试写一下基于XMLHttpRequest对象的拦截器吗?
欢迎你在留言区和我交流。如果觉得有所收获,也可以把课程分享给更多的朋友一起学习。我们下节课见!
- 苏果果 👍(0) 💬(0)
完整源码入口: https://github.com/sankyutang/fontend-trace-geekbang-course
2024-07-18 - . 👍(0) 💬(1)
有代码仓库吗?
2024-07-02 - westfall 👍(0) 💬(2)
现在基本上都是 axios 一把梭了
2024-05-08 - Aaaaaaaaaaayou 👍(0) 💬(0)
代码好像有问题。 const err = new Error('TimeoutAbortError'); err.name 应该是 Error,err.message 才是 TimeoutAbortError
2024-09-20 - JuneRain 👍(0) 💬(0)
对利用 AbortController 结合 setTimeout 实现请求中断这里有点疑问: 在项目规模较大的情况下,某些场景短时间内会发起很多请求,例如进入首页的场景。这种情况下,大量的 setTimeout 是否会有性能问题?
2024-05-16