Axios Node 端请求是如何实现的?

2024年 6月 5日 64.5k 0

本文我们将讨论 axios 的 Node 环境实现。我们都知道使用 axios 可以让我们在浏览器和 Node 端获得一致的使用体验。这部分是通过适配器模式来实现的。

axios 内置了 2 个适配器(截止到 v1.6.8 版本)[8]:xhr.js 和 http.js。

Axios Node 端请求是如何实现的?-1图片

顾名思义,xhr.js 是针对浏览器环境提供的 XMLHttpRequest 封装的;http.js 则是针对 Node 端的 http/https 模块进行封装的。

不久前,我们详细讲解了浏览器端的实现,本文就来看看 Node 环境又是如何实现的。

Node 端请求案例

老规矩,在介绍实现之前,先看看 axios 在浏览器器环境的使用。

首先创建项目,安装 axios 依赖:

mdir axios-demos
cd axios-demos
npm init
npm install axios
# 使用 VS Code 打开当前目录
code .

写一个测试文件 index.js:

// index.js
const axios = require('axios')

axios.get('https://httpstat.us/200')
  .then(res => {
    console.log('res >>>>', res)
  })

执行文件:

node --watch index.js

注意:--watch[9] 是 Node.js 在 v16.19.0 版本引入的实验特性,在 v22.0.0 已转为正式特性。

打印出来结果类似:

Restarting 'index.js'
res >>>> {
  status: 200,
  statusText: 'OK'
  headers: Object [AxiosHeaders] {}
  config: {}
  request:  ClientRequest {}
  data: { code: 200, description: 'OK' }
}
Completed running 'index.js'

修改 Index.js 文件内容保存:

const axios = require('axios')

axios.get('https://httpstat.us/404')
  .catch(err => {
    console.log('err >>>>', err)
  })

打印结果类似:

Restarting 'index.js'
err >>>> AxiosError: Request failed with status code 404 {
  code: 'ERR_BAD_REQUEST',
  config: {}
  request:  ClientRequest {}
  response: {
    status: 404,
    statusText: 'Not Found',
    data: { code: 404, description: 'Not Found' }
  }
}

以上我们就算讲完了 axios 在 Node 端的简单使用,这就是 axios 好处所在,统一的使用体验,免去了我们在跨平台的学习成本,提升了开发体验。

源码分析

接下来就来看看 axios 的 Node 端实现。源代码位于 lib/adapters/http.js[10] 下。

// /v1.6.8/lib/adapters/http.js#L160
export default isHttpAdapterSupported && function httpAdapter(config) {/* ... */}

Node 端发出的请求最终都是交由 httpAdapter(config) 函数处理的,其核心实现如下:

import http from 'http';
import https from 'https';

export default isHttpAdapterSupported && function httpAdapter(config) {
  // 1)
  return wrapAsync(async function dispatchHttpRequest(resolve, reject, onDone) {
    
    // 2)
    let {data, lookup, family} = config;
    const {responseType, responseEncoding} = config;
    const method = config.method.toUpperCase();
    
    // Parse url
    const fullPath = buildFullPath(config.baseURL, config.url);
    const parsed = new URL(fullPath, 'http://localhost');
    
    const headers = AxiosHeaders.from(config.headers).normalize();
    
    if (data && !utils.isStream(data)) {
      if (Buffer.isBuffer(data)) {
        // Nothing to do...
      } else if (utils.isArrayBuffer(data)) {
        data = Buffer.from(new Uint8Array(data));
      } else if (utils.isString(data)) {
        data = Buffer.from(data, 'utf-8');
      } else {
        return reject(new AxiosError(
          'Data after transformation must be a string, an ArrayBuffer, a Buffer, or a Stream',
          AxiosError.ERR_BAD_REQUEST,
          config
        ));
      }
    }
    
    const options = {
      path,
      method: method,
      headers: headers.toJSON(),
      agents: { http: config.httpAgent, https: config.httpsAgent },
      auth,
      protocol,
      family,
      beforeRedirect: dispatchBeforeRedirect,
      beforeRedirects: {}
    };
    
    // 3)  
    let transport;
    const isHttpsRequest = /https:?/.test(options.protocol);
    
    if (config.maxRedirects === 0) {
      transport = isHttpsRequest ? https : http;
    }
    
    // Create the request
    req = transport.request(options, function handleResponse(res) {
      // ...
    }
    
    // 4)
    // Handle errors
    req.on('error', function handleRequestError(err) {
      // @todo remove
      // if (req.aborted && err.code !== AxiosError.ERR_FR_TOO_MANY_REDIRECTS) return;
      reject(AxiosError.from(err, null, config, req));
    });
    
    // 5)
    // Handle request timeout
    if (config.timeout) {
      req.setTimeout(timeout, function handleRequestTimeout() {
        if (isDone) return;
        let timeoutErrorMessage = config.timeout ? 'timeout of ' + config.timeout + 'ms exceeded' : 'timeout exceeded';
        const transitional = config.transitional || transitionalDefaults;
        if (config.timeoutErrorMessage) {
          timeoutErrorMessage = config.timeoutErrorMessage;
        }
        reject(new AxiosError(
          timeoutErrorMessage,
          transitional.clarifyTimeoutError ? AxiosError.ETIMEDOUT : AxiosError.ECONNABORTED,
          config,
          req
        ));
        abort();
      });
    }
    
    // 6)
    // Send the request
    if (utils.isStream(data)) {
      let ended = false;
      let errored = false;

      data.on('end', () => {
        ended = true;
      });

      data.once('error', err => {
        errored = true;
        req.destroy(err);
      });

      data.on('close', () => {
        if (!ended && !errored) {
          abort(new CanceledError('Request stream has been aborted', config, req));
        }
      });

      data.pipe(req);
    } else {
      req.end(data);
    }
  }

是有点长,但大概浏览一遍就行,后面会详细讲。实现主要有 6 部分:

  • 这里的 wrapAsync 是对 return new Promise((resolve, resolve) => {}) 的包装,暴露出 resolve、reject 供 dispatchHttpRequest 函数内部调用使用,代表请求成功或失败
  • 接下里,就是根据传入的 config 信息组装请求参数 options 了
  • axios 会根据传入的 url 的协议,决定是采用 http 还是 https 模块创建请求
  • 监听请求 req 上的异常(error)事件
  • 跟 4) 一样,不过监听的是请求 req 上的超时事件。而其他诸如取消请求、完成请求等其他兼容事件则是在 2) 创建请求的回调函数 handleResponse(res) 中处理的
  • 最后,调用 req.end(data) 发送请求即可。当然,这里会针对 data 是 Stream 类型的情况特别处理一下
  • 大概介绍了之后,我们再深入每一步具体学习一下。

    包装函数 wrapAsync

    首先,httpAdapter(config) 内部的实现是经过 wrapAsync 包装函数返回的。

    // /v1.6.8/lib/adapters/http.js#L122-L145
    const wrapAsync = (asyncExecutor) => {
      return new Promise((resolve, reject) => {
        let onDone;
        let isDone;
    
        const done = (value, isRejected) => {
          if (isDone) return;
          isDone = true;
          onDone && onDone(value, isRejected);
        }
    
        const _resolve = (value) => {
          done(value);
          resolve(value);
        };
    
        const _reject = (reason) => {
          done(reason, true);
          reject(reason);
        }
    
        asyncExecutor(_resolve, _reject, (onDoneHandler) => (onDone = onDoneHandler)).catch(_reject);
      })
    };

    调用 wrapAsync 函数会返回一个 Promise 对象,除了跟原生 Promise 构造函数一样会返回 resolve、reject 之外,还额外拓展了一个 onDone 参数,确保 Promise 状态改变后,总是会调用 onDone。

    组装请求参数

    在处理好返回值后,接下来要做的就是组装请求参数了,请求参数最终会交由 http.request(options)[11]/https.request(options)[12] 处理,因此需要符合其类型定义。

    http 模块的请求案例

    在理解 options 参数之前,先了解一下 http 模块的请求案例。

    const http = require('node:http');
    
    const options = {
      hostname: 'www.google.com',
      port: 80,
      path: '/upload',
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
        'Content-Length': Buffer.byteLength(postData),
      },
    };
    
    const req = http.request(options, (res) => {
      console.log(`STATUS: ${res.statusCode}`);
      console.log(`HEADERS: ${JSON.stringify(res.headers)}`);
      res.setEncoding('utf8');
      res.on('data', (chunk) => {
        console.log(`BODY: ${chunk}`);
      });
      res.on('end', () => {
        console.log('No more data in response.');
      });
    });
    
    req.on('error', (e) => {
      console.error(`problem with request: ${e.message}`);
    });
    
    req.end(JSON.stringify({
      'msg': 'Hello World!',
    }));

    以上,我们向 http://www.google.com/upload 发起了一个 POST 请求(https 请求与此类次)。

    值得注意的是,请求参数 options 中并不包含请求体数据,请求体数据最终是以 req.end(data) 发动出去的,这一点跟 XMLHttpRequest 实例的做法类似。

    组装请求参数

    再来看看 axios 中关于这块请求参数的组装逻辑。

    首先,使用 .baseURL 和 .url 参数解析出跟 URL 相关数据。

    /v1.6.8/lib/adapters/http.js#L221
    // Parse url
    const fullPath = buildFullPath(config.baseURL, config.url);
    const parsed = new URL(fullPath, 'http://localhost');
    const protocol = parsed.protocol || supportedProtocols[0];

    不支持的请求协议会报错。

    // /v1.6.8/lib/platform/node/index.js#L11
    protocols: [ 'http', 'https', 'file', 'data' ]
    // /v1.6.8/lib/adapters/http.js#L44
    const supportedProtocols = platform.protocols.map(protocol => {
      return protocol + ':';
    });
    
    // /v1.6.8/lib/adapters/http.js#L265-L271
    if (supportedProtocols.indexOf(protocol) === -1) {
      return reject(new AxiosError(
        'Unsupported protocol ' + protocol,
        AxiosError.ERR_BAD_REQUEST,
        config
      ));
    }

    错误 CODE 是 ERR_BAD_REQUEST,类似 4xx 错误。

    接下来,将 headers 参数转成 AxiosHeaders 实例。

    // /v1.6.8/lib/adapters/http.js#L273
    const headers = AxiosHeaders.from(config.headers).normalize();

    最后,处理下请求体数据 config.data。

    // /v1.6.8/lib/adapters/http.js#L287-L326
    // support for spec compliant FormData objects
    if (utils.isSpecCompliantForm(data)) {
      const userBoundary = headers.getContentType(/boundary=([-_\w\d]{10,70})/i);
    
      data = formDataToStream(data, (formHeaders) => {
        headers.set(formHeaders);
      }, {
        tag: `axios-${VERSION}-boundary`,
        boundary: userBoundary && userBoundary[1] || undefined
      });
      // support for https://www.npmjs.com/package/form-data api
    } else if (utils.isFormData(data) && utils.isFunction(data.getHeaders)) {
      headers.set(data.getHeaders());
    
      if (!headers.hasContentLength()) {
        try {
          const knownLength = await util.promisify(data.getLength).call(data);
          Number.isFinite(knownLength) && knownLength >= 0 && headers.setContentLength(knownLength);
          /*eslint no-empty:0*/
        } catch (e) {
        }
      }
    } else if (utils.isBlob(data)) {
      data.size && headers.setContentType(data.type || 'application/octet-stream');
      headers.setContentLength(data.size || 0);
      data = stream.Readable.from(readBlob(data));
    } else if (data && !utils.isStream(data)) {
      if (Buffer.isBuffer(data)) {
        // Nothing to do...
      } else if (utils.isArrayBuffer(data)) {
        data = Buffer.from(new Uint8Array(data));
      } else if (utils.isString(data)) {
        data = Buffer.from(data, 'utf-8');
      } else {
        return reject(new AxiosError(
          'Data after transformation must be a string, an ArrayBuffer, a Buffer, or a Stream',
          AxiosError.ERR_BAD_REQUEST,
          config
        ));
      }

    axios 会针对传入的不同类型的 config.data 做统一处理,最终不是处理成 Stream 就是处理成 Buffer。

    不过,当传入的 data 是对象时,在调用 httpAdapter(config) 之前,会先经过 transformRequest() 函数处理成字符串。

    // /v1.6.8/lib/defaults/index.js#L91-L94
    if (isObjectPayload || hasJSONContentType ) {
      headers.setContentType('application/json', false);
      return stringifySafely(data);
    }

    针对这个场景,data 会进入到下面的处理逻辑,将字符串处理成 Buffer。

    // /v1.6.8/lib/adapters/http.js#L287-L326
    if (utils.isString(data)) {
      data = Buffer.from(data, 'utf-8');
    }

    然后,获得请求路径 path。

    // /v1.6.8/lib/adapters/http.js#L384C4-L397C1
    try {
      path = buildURL(
        parsed.pathname + parsed.search,
        config.params,
        config.paramsSerializer
      ).replace(/^\?/, '');
    } catch (err) {
       // ...
    }

    最后,组装 options 参数。

    // /v1.6.8/lib/adapters/http.js#L403C1-L413C7
    const options = {
      path,
      method: method,
      headers: headers.toJSON(),
      agents: { http: config.httpAgent, https: config.httpsAgent },
      auth,
      protocol,
      family,
      beforeRedirect: dispatchBeforeRedirect,
      beforeRedirects: {}
    };

    创建请求

    再看创建请求环节。

    获得请求实例

    首先,是获得请求实例。

    import followRedirects from 'follow-redirects';
    const {http: httpFollow, https: httpsFollow} = followRedirects;
    
    // /v1.6.8/lib/adapters/http.js#L426-L441
    let transport;
    const isHttpsRequest = isHttps.test(options.protocol);
    options.agent = isHttpsRequest ? config.httpsAgent : config.httpAgent;
    if (config.transport) {
      transport = config.transport;
    } else if (config.maxRedirects === 0) {
      transport = isHttpsRequest ? https : http;
    } else {
      if (config.maxRedirects) {
        options.maxRedirects = config.maxRedirects;
      }
      if (config.beforeRedirect) {
        options.beforeRedirects.config = config.beforeRedirect;
      }
      transport = isHttpsRequest ? httpsFollow : httpFollow;
    }

    如上所示,你可以通过 config.transport 传入,但通常不会这么做。否则,axios 内部会根据你是否传入 config.maxRedirects(默认 undefined) 决定使用原生 http/https 模块还是 follow-redirects 包里提供的 http/https 方法。

    如果没有传入 config.maxRedirects,axios 默认会使用 follow-redirects 包里提供的 http/https 方法发起请求,它的用法跟原生 http/https 模块一样,这里甚至可以只使用 follow-redirects 就够了。

    创建请求

    下面就是创建请求了。

    // Create the request
    req = transport.request(options, function handleResponse(res) {}

    我们在 handleResponse 回调函数里处理返回数据 res。

    function request(options: RequestOptions | string | URL, callback?: (res: IncomingMessage) => void): ClientRequest;
    function request(
        url: string | URL,
        options: RequestOptions,
        callback?: (res: IncomingMessage) => void,
    ): ClientRequest;

    根据定义,我们知道 res 是 IncomingMessage 类型,继承自 stream.Readable[13],是一种可读的 Stream。

    const readable = getReadableStreamSomehow();
    readable.on('data', (chunk) => {
      console.log(`Received ${chunk.length} bytes of data.`);
    });

    res 的处理我们会放到处理请求一节讲述,下面就是发出请求了。

    发出请求

    这部分代码比较简单,而数据体也是在这里传入的。

    // /v1.6.8/lib/adapters/http.js#L658C5-L681C6
    // Send the request
    if (utils.isStream(data)) {
      let ended = false;
      let errored = false;
    
      data.on('end', () => {
        ended = true;
      });
    
      data.once('error', err => {
        errored = true;
        req.destroy(err);
      });
    
      data.on('close', () => {
        if (!ended && !errored) {
          abort(new CanceledError('Request stream has been aborted', config, req));
        }
      });
    
      data.pipe(req);
    } else {
      req.end(data);
    }

    如果你的请求体是 Buffer 类型的,那么直接传入 req.end(data) 即可,否则(Stream 类型)则需要以管道形式传递给 req。

    处理请求

    接着创建请求一节,下面开始分析请求的处理。

    Node.js 部分的请求处理,比处理 XMLHttpRequest 稍微复杂一些。你要在 2 个地方做监听处理。

  • transport.request 返回的 req 实例
  • 另一个,则是 transport.request 回调函数 handleResponse 返回的 res(也就是 responseStream)
  • 监听 responseStream

    首先,用 res/responseStream 上已有的信息组装响应数据 response。

    // /v1.6.8/lib/adapters/http.js#L478
    // decompress the response body transparently if required
    let responseStream = res;
    
    // return the last request in case of redirects
    const lastRequest = res.req || req;
    
    const response = {
      status: res.statusCode,
      statusText: res.statusMessage,
      headers: new AxiosHeaders(res.headers),
      config,
      request: lastRequest
    };

    这是不完整的,因为我们还没有设置 response.data。

    // /v1.6.8/lib/adapters/http.js#L535C7-L538C15
    if (responseType === 'stream') {
      response.data = responseStream;
      settle(resolve, reject, response);
    } else {
      // ...
    }

    如果用户需要的是响应类型是 stream,那么一切就变得简单了,直接将数据都给 settle 函数即可。

    // /v1.6.8/lib/core/settle.js
    export default function settle(resolve, reject, response) {
      const validateStatus = response.config.validateStatus;
      if (!response.status || !validateStatus || validateStatus(response.status)) {
        resolve(response);
      } else {
        reject(new AxiosError(
          'Request failed with status code ' + response.status,
          [AxiosError.ERR_BAD_REQUEST, AxiosError.ERR_BAD_RESPONSE][Math.floor(response.status / 100) - 4],
          response.config,
          response.request,
          response
        ));
      }
    }

    settle 函数会根据传入的 response.status 和 config.validateStatus() 决定请求是成功(resolve)还是失败(reject)。

    当然,如果需要的响应类型不是 stream,就监听 responseStream 对象上的事件,处理请求结果。

    // /v1.6.8/lib/adapters/http.js#L538C1-L591C8
    } else {
      const responseBuffer = [];
      let totalResponseBytes = 0;
    
      // 1)
      responseStream.on('data', function handleStreamData(chunk) {
        responseBuffer.push(chunk);
        totalResponseBytes += chunk.length;
    
        // make sure the content length is not over the maxContentLength if specified
        if (config.maxContentLength > -1 && totalResponseBytes > config.maxContentLength) {
          // stream.destroy() emit aborted event before calling reject() on Node.js v16
          rejected = true;
          responseStream.destroy();
          reject(new AxiosError('maxContentLength size of ' + config.maxContentLength + ' exceeded',
            AxiosError.ERR_BAD_RESPONSE, config, lastRequest));
        }
      });
      
      // 2)
      responseStream.on('aborted', function handlerStreamAborted() {
        if (rejected) {
          return;
        }
    
        const err = new AxiosError(
          'maxContentLength size of ' + config.maxContentLength + ' exceeded',
          AxiosError.ERR_BAD_RESPONSE,
          config,
          lastRequest
        );
        responseStream.destroy(err);
        reject(err);
      });
    
      // 3)
      responseStream.on('error', function handleStreamError(err) {
        if (req.destroyed) return;
        reject(AxiosError.from(err, null, config, lastRequest));
      });
      
      // 4)
      responseStream.on('end', function handleStreamEnd() {
        try {
          let responseData = responseBuffer.length === 1 ? responseBuffer[0] : Buffer.concat(responseBuffer);
          if (responseType !== 'arraybuffer') {
            responseData = responseData.toString(responseEncoding);
            if (!responseEncoding || responseEncoding === 'utf8') {
              responseData = utils.stripBOM(responseData);
            }
          }
          response.data = responseData;
        } catch (err) {
          return reject(AxiosError.from(err, null, config, response.request, response));
        }
        settle(resolve, reject, response);
      });
    }

    responseStream 上会监听 4 个事件。

  • data:Node 请求的响应默认都是以流数据形式接收的,而 data 就是在接收过程中会不断触发的事件。我们在这里将接收到的数据存储在 responseBuffer 中,以便后续使用
  • aborted:会在接收响应数据超过时,或是调用 .destory() 时触发
  • err:在流数据接收错误时调用
  • end:数据结束接收,将收集到的 responseBuffer 先转换成 Buffer 类型,再转换成字符串,最终赋值给 response.data
  • 监听 req

    以上,我们完成了对响应数据的监听。我们再来看看,对请求实例 req 的监听。

    // /v1.6.8/lib/adapters/http.js#L606
    // Handle errors
    req.on('error', function handleRequestError(err) {
      // @todo remove
      // if (req.aborted && err.code !== AxiosError.ERR_FR_TOO_MANY_REDIRECTS) return;
      reject(AxiosError.from(err, null, config, req));
    });
    
    // /v1.6.8/lib/adapters/http.js#L619
    // Handle request timeout
    if (config.timeout) {
      req.setTimeout(timeout, function handleRequestTimeout() {
        if (isDone) return;
        let timeoutErrorMessage = config.timeout ? 'timeout of ' + config.timeout + 'ms exceeded' : 'timeout exceeded';
        const transitional = config.transitional || transitionalDefaults;
        if (config.timeoutErrorMessage) {
          timeoutErrorMessage = config.timeoutErrorMessage;
        }
        reject(new AxiosError(
          timeoutErrorMessage,
          transitional.clarifyTimeoutError ? AxiosError.ETIMEDOUT : AxiosError.ECONNABORTED,
          config,
          req
        ));
        abort();
      });
    }

    一共监听了 2 个事件:

  • error:请求出错
  • req.setTimeout():请求超时
  • 以上,我们就完成了请求处理的所有内容。可以发现,Node 端处理请求的逻辑会比浏览器端稍微复杂一些:你需要同时监听请求实例以及响应流数据上的事件,确保整个请求过程被完整监听。

    总结

    本文主要带大家学习了 axios 的 Node 端实现。

    相比较于浏览器端要稍微复杂一些,不仅是因为我们要考虑请求可能的最大跳转(maxRedirects),还要同时监听请求实例以及响应流数据上的事件,确保整个请求过程被完整监听。

    参考资料

    [1]axios 是如何实现取消请求的?: https://juejin.cn/post/7359444013894811689

    [2]你知道吗?axios 请求是 JSON 响应优先的: https://juejin.cn/post/7359580605320036415

    [3]axios 跨端架构是如何实现的?: https://juejin.cn/post/7362119848660451391

    [4]axios 拦截器机制是如何实现的?: https://juejin.cn/post/7363545737874161703

    [5]axios 浏览器端请求是如何实现的?: https://juejin.cn/post/7363928569028821029

    [6]axios 对外出口API是如何设计的?: https://juejin.cn/post/7364614337371308071

    [7]axios 中是如何处理异常的?: https://juejin.cn/post/7369951085194739775

    [8]axios 内置了 2 个适配器(截止到 v1.6.8 版本): https://github.com/axios/axios/tree/v1.6.8/lib/adapters

    [9]--watch: https://nodejs.org/api/cli.html#--watch

    [10]lib/adapters/http.js: https://github.com/axios/axios/blob/v1.6.8/lib/adapters/http.js

    [11]http.request(options): https://nodejs.org/docs/latest/api/http.html#httprequestoptions-callback

    [12]https.request(options): https://nodejs.org/docs/latest/api/https.html#httpsrequestoptions-callback

    [13]stream.Readable: https://nodejs.org/docs/latest/api/stream.html#class-streamreadable

    相关文章

    JavaScript2024新功能:Object.groupBy、正则表达式v标志
    PHP trim 函数对多字节字符的使用和限制
    新函数 json_validate() 、randomizer 类扩展…20 个PHP 8.3 新特性全面解析
    使用HTMX为WordPress增效:如何在不使用复杂框架的情况下增强平台功能
    为React 19做准备:WordPress 6.6用户指南
    如何删除WordPress中的所有评论

    发布评论