likes
comments
collection
share

AbortController到底值不值得“入手”

作者站长头像
站长
· 阅读数 19

这篇文章来自我们团队啸达同学的分享,他介绍了Node.js 16中的AbortController,对中断感兴趣的小伙伴可以了解下,另外,你熟悉的语言都是如何进行这些中断处理的呢?

今年3月份的时候,我制作了一个NodeJS的基础镜像。当时NodeJS的稳定版本还是16.14.2,谁成想才过了一个多月,NodeJS18就已经变成Available状态了。眼瞅着NodeJS18都快出LTS版本了,我自己连NodeJS16还没整明白呢。只能感叹一下前端真的也太卷了吧。

AbortController到底值不值得“入手”

NodeJS16版本除了日常编译器和引擎上的一些提升以外,有一个特性引起我的注意:AbortController。准确的说,这是NodeJS15引入的新特性,放在NodeJS16中讲是因为我一般会跳过NodeJS的奇数版本,否则要学的太多了,发量不太支持。

什么是AbortController

NodeJS是一种单线程的语言,虽然这么说不太严谨,但在每个V8引擎中,JS的执行过程都是单线程的。为了做到JS的非阻塞执行,V8引擎中提供了很多诸如网络请求、文件读写等异步能力。一般情况,我们通过异步回调去获取异步执行的结果。

但是,偏偏有的时候,如果一个异步过程执行的时间太久了;或是我们在等待异步执行结果的时候发现其实异步执行的结果对我们并不重要的时候,我们就想提前终止我们代码中的异步行为。如果有些人用过RxJS的话,应该对这里的概念不陌生。

RxJS有丰富的操作符,我们可以通过这些操作符去规范流的行为,比如在你认为合适的时间去终止一个流、暂停一个流、或过滤掉流中无用的数据。但是这样的操作对于NodeJS的异步操作来说一直没有一个特别好的办法。

AbortController到底值不值得“入手”

AbortController的出现就是用来解决上述问题的,它可以终止一个异步行为,并由NodeJS原生提供。接下来我们就一起通过AbortControllerAbortSignal看下如何通过这些API实现Abort的功能。

快速上手

/** A controller object that allows you to abort one or more DOM requests as and when desired. */
interface AbortController {
    /**
     * Returns the AbortSignal object associated with this object.
     */

    readonly signal: AbortSignal;
    /**
     * Invoking this method will set this object's AbortSignal's aborted flag and signal to any observers that the associated activity is to be aborted.
     */
    abort(): void;
}

/** A signal object that allows you to communicate with a DOM request (such as a Fetch) and abort it if required via an AbortController object. */
interface AbortSignal {
    /**
     * Returns true if this AbortSignal's AbortController has signaled to abort, and false otherwise.
     */
    readonly aborted: boolean;
}

AbortControllerAbortSignal类型定义非常简单,AbortController提供一个只读的中断标识和一个中断函数,中断标识其实就是一个布尔属性。当我们尝试调用AbortControllerabort()函数时,AbortController的标识位会变成true。同时,abort()函数的调用还会触发一个abort事件,如果想监听这个abort事件,可以在AbortControllersignal上添加或移除abort的事件监听。大致就是如下这个样子:

const controller = new AbortController();
const { signal } = controller;

const abortEventListener = (event) => {
  console.log(signal.aborted); // true
};

signal.addEventListener("abort", abortEventListener);
controller.abort();
signal.removeEventListener("abort", abortEventListener);

效果检验

初步对AbortController有个认识后,我们来看下这东西到底好不好用,俗话说实践是检验真理的唯一标准,本次实验中,我会针对Promise和异步请求两个场景看下AbortController的中断效果怎样。先做一些准备工作:

确保的Node版本是16+版本

AbortController到底值不值得“入手”

创建AbortController实例

const ac = new AbortController();

ac.signal.addEventListener('abort', (event) => {
  console.log('此路是我开,此树是我栽!', event);
});

本次实验中,中断会在2.5s时触发

async function abort() {
  await setTimeout(2500);
  ac.abort();
  return 'abort';
}

这里补充一下,await setTimeout(2500);这种用法也是NodeJS16的一个新特性,感兴趣的可以自行查阅:nodejs.org/api/timers.…

中断Promise

先构建一个支持中断的Promise,NodeJS16中timers/promises包下的setTimeout是支持中断,只要在其timeOption中关联一个中断信号即可(这里的ac就是上一节中我们定义的AbortController)。只要有API的加持,想要完成中断真的很方便,真的是干净又卫生啊兄弟们!

AbortController到底值不值得“入手”

/**
 * 获取一个Promise
 *
 * @param {boolean} [abort=false] 该Promise是否支持中断
 * @return {*} 
 */
async function getPromise(abort = false) {
  let answer = '';

  for (let i = 1; i <= 5; i++) {
    if (abort) {
      answer = await setTimeout(1000, `[getPromise] say: executing NO.${i} times`, {
        signal: ac.signal,
      });
    } else {
      answer = await setTimeout(1000, `[getPromise] say: executing NO.${i} times`);
    }

    console.log(answer);
  }
  return answer;
}

这里说明一点,为什么要把一个延时5s响应的Promise放在一个for循环中,这是为了实验效果而做的,方便在后续的实验中让大家清晰地看到中断的效果。

实验效果

我们先看一下没有开启中断的效果,简单执行一下getPromise(false);

[getPromise] say: executing NO.1 times
[getPromise] say: executing NO.2 times
[getPromise] say: executing NO.3 times
[getPromise] say: executing NO.4 times
[getPromise] say: executing NO.5 times

开启中断,这里使用Promise.all把延时中断和异步Promise同时执行,代码和执行效果如下:

// 测试中断场景
const promiseArr = [abort(), getPromise(true)];

Promise.all(promiseArr).then((data) => {
  console.log(data);
}).catch((err) => {
  console.error('悲催啊!我是一个被中断的Promise!', err);
});
[getPromise] say: executing NO.1 times
[getPromise] say: executing NO.2 times
此路是我开,此树是我栽! Event {
  type: 'abort',
  defaultPrevented: false,
  cancelable: false,
  timeStamp: 3875.694300174713
}
悲催啊!我是一个被中断的PromiseAbortError: The operation was aborted
    at Timeout.cancelListenerHandler (node:timers/promises:43:12)
    at AbortSignal.[nodejs.internal.kHybridDispatch] (node:internal/event_target:643:20)
    at AbortSignal.dispatchEvent (node:internal/event_target:585:26)
    at abortSignal (node:internal/abort_controller:284:10)
    at AbortController.abort (node:internal/abort_controller:315:5)
    at abort (d:\code\local\empty-egg\test.ts:11:6)
    at async Promise.all (index 0) {
  code: 'ABORT_ERR'
}

中断在2.5s处生效,我们可以看到后3个answer = await setTimeout(...)是没有执行的。

race vs AbortController

那么在AbortController出现之前,我一直是使用Promise.race进行“中断”操作的,不过Promise.race是不是能真的中断这里是需要打个问号的,我们把代码改一下看下效果。

AbortController到底值不值得“入手”

// 测试race场景
const promiseArr = [new Promise((_, reject) => {
  global.setTimeout(() => {
    reject('Race Abort');
  }, 2500);
}), getPromise(false)];

Promise.race(promiseArr).then((data) => {
  console.log(data);
}).catch((err) => {
  console.error('悲催啊!我是一个被中断的Promise!', err);
});

// [getPromise] say: executing NO.1 times
// [getPromise] say: executing NO.2 times
// 悲催啊!我是一个被中断的Promise! Race Abort
// [getPromise] say: executing NO.3 times
// [getPromise] say: executing NO.4 times
// [getPromise] say: executing NO.5 times

在构建reject的Promise中我使用了global.setTimeout,这里是原生的那个setTimeout。至于为什么要这样,是因为目前我还没有发现如何使用timers/promises包下的setTimeout构建一个rejected的Promise,所以这里又用回了原生的setTimeout。这里没有知识点和骚操作,单纯就是能力不行!

我们可以看到在2.5s以后,Promise.race的状态变成rejected了并且提前返回了结果,但是getPromise并未终止执行,还把第3~5次的日志输出了。其实这就是race vs AbortController的最大区别,AbortController是真的可以终止一个Promise,而Promise.race只是做做样子,让你看起来、误以为异步提前终止了。所以,从这点看,AbortController还是有值得入手的地方的。

中断Http请求

中断请求的话就需要先构建一个Web应用,我这里起了一个Egg应用模拟接下来的Abort操作。除了AbortController的初始化和监听以外,我们先创建一个时延为5s的API请求。这个/slow-request请求跟之前的getPromise类似,执行过程1秒1秒分开延时,方便观察中断效果。

// router.ts
router.get("/slow-request", controller.sxd.slowRequest);
// controller/sxd.ts
  public async slowRequest() {
    const { ctx } = this;

    for (let i = 1; i <= 5; i++) {
      await setTimeout(1000);
      console.log(`[slowRequest] say: executing NO.${i} times`);
    }

    ctx.body = '[slowRequest] say: exec after 5000';
  }

接下来,构建两个测试请求,以中断方式和非中断方式分别调用/slow-request请求,通过控制台和响应观察中断效果。

AbortController到底值不值得“入手”

实验效果

老规矩,第一口粉丝先吃。 额~说错了,老规矩先看一下,没有中断操作的效果。

  public async justLongRequest() {
    const { ctx } = this;

    try {
      const res = await this.ctx.curl('http://127.0.0.1:7001/slow-request', {
        timeout: [3000, 30000],
      });
      ctx.body = res.data;
    } catch (error) {
      ctx.body = `urllib timeout! ${error}`;
    }
  }

justLongRequest方法是/request请求的处理函数,由于使用的是Egg应用,Egg中自带的http组件是urllib。这个urllib可以通过timeout设置连接和响应超时时间(默认为5s),所以这里认为把timeout时间设长一点,避免影响演示效果。

Postman

AbortController到底值不值得“入手”

Console控制台

[slowRequest] say: executing NO.1 times
[slowRequest] say: executing NO.2 times
[slowRequest] say: executing NO.3 times
[slowRequest] say: executing NO.4 times
[slowRequest] say: executing NO.5 times

添加中断效果:

import { fetch } from "undici";

export default class SxdController extends Controller {

  // ...

  public async longRequestButCanAbort() {
    const { ctx } = this;

    const PromiseArr: Promise<any>[] = [
      this.abort(),
      fetch("http://127.0.0.1:7001/slow-request", {
        signal: ac.signal,
      }),
    ];

    try {
      const res = await Promise.all(PromiseArr);
      ctx.body = res;
    } catch (error) {
      ctx.body = `悲催啊!我是一个被中断的HTTP!, ${error}`;
    }
  }
  
  private async abort() {
    await setTimeout(2500);
    ac.abort();
    return "abort";
  }

}

代码中使用了一个新的库undici,这里大家先不用关心这个库,后面会讲到。目前就知道它是一个符合fetch规范的HTTP库,并且支持通过AbortController中断请求就行。

Postman

AbortController到底值不值得“入手”

Console控制台

[slowRequest] say: executing NO.1 times
[slowRequest] say: executing NO.2 times
此路是我开,此树是我栽 Event {
  type: 'abort',
  defaultPrevented: false,
  cancelable: false,
  timeStamp: 10303.782700061798
}
[slowRequest] say: executing NO.3 times
[slowRequest] say: executing NO.4 times
[slowRequest] say: executing NO.5 times

这次Http的响应在2.53s左右返回了,但是目标请求实际上是在5s后才执行完毕的。这是不是可以说明AbortController操作实际上无法终止一个HTTP请求呢?我继续在上面代码的基础上做了一些调整,打印了一些HTTP连接的状态和事件,这里再测试一下效果。

  public async slowRequest() {
    const { ctx } = this;

    // 监听连接关闭事件
    ctx.res.on('close', () => {
      console.error('slowRequest has been destoryed!');
    });

    for (let i = 1; i <= 5; i++) {
      await setTimeout(1000);
      console.log(`[slowRequest] say: executing NO.${i} times`);
    }

    // 查看Http连接是否已经销毁
    console.log(ctx.res.destroyed);
    ctx.body = "[slowRequest] say: exec after 5000";
  }

Postman

AbortController到底值不值得“入手”

Console控制台

此路是我开,此树是我栽 Event {
  type: 'abort',
  defaultPrevented: false,
  cancelable: false,
  timeStamp: 9087.221799850464
}

// ctx.res.on('close', () => {
//   console.error('slowRequest has been destoryed!');
// });
slowRequest has been destoryed!

[slowRequest] say: executing NO.3 times
[slowRequest] say: executing NO.4 times
[slowRequest] say: executing NO.5 times

// console.log(ctx.res.destroyed);
true

调整后的slowRequest增加了两处打印,第一处是对HTTP的close事件的监听。从日志可以看出,2.5s时,当中断触发时,HTTP的close事件就已经被捕获到;5s后,当延时请求执行完毕后,我们发现HTTP的状态为destroyed的,说明连接早已关闭。所以,这个测试说明AbortController是可以通过触发中断事件从而提前关闭一个HTTP请求的。

所以这里做个简单的总结,AbortController中断的是HTTP连接,当有一个连接长时间未取得响应的时候,可以通过AbortController提前中断HTTP连接,已达到提前响应、节省连接的目的。

undici

最后再补充一点,上文中使用了HTTP请求的库undici,这里简单说明一下使用这个库的原因。首先,Egg中自带的HttpClient是由urllib实现的。这个库虽然已经比较稳定,但是目前尚没有得知它支持AbortController的消息。虽然urllib中已经提供了timeout这种请求超时的能力,但是我们从urllib的依赖上看,它应该不是通过AbortController实现其超时中断的能力的。

undici是NodeJS官方去年推出一个库,并且作为一个实验性特性集成进了NodeJS18中,最重要的一点是它支持了AbortController。我这里完全是想试用一下这个新的库所以在演示中使用了undici。这里,如果你想使用别的框架或是HttpClient(前提是支持AbortController)做上述验证都是可以的。

最后,给出一些主流的HttpClient的依赖支持。从依赖中看,大部分主流库都是支持AbortController的,可以放心使用:

AbortController到底值不值得“入手”

一点点总结

如果按10分制度的话,本次测评我给AbortController9分。总的来说,AbortController使用起来真的很方便,再加上一些API和库的支持,用起来就更省心了。如果NodeJS后续还能集成更多对异步任务操作就更好了,比如说暂停|恢复一个异步执行操作。

最后,花钱的坑我来踩,免费的关注请给一个吧!