跳转至

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范围。这类状态码说明存在网络问题或者服务问题,属于严重级别,我们可以在日志中使用 errorhight 以及 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对象的拦截器吗?

欢迎你在留言区和我交流。如果觉得有所收获,也可以把课程分享给更多的朋友一起学习。我们下节课见!

精选留言(5)
  • 苏果果 👍(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