Loading...
墨滴

DYBOY

2021/11/19  阅读:31  主题:自定义主题1

刚出锅的 Axios 网络请求源码阅读笔记

项目中一直都有用到 Axios 作为网络请求工具,用它更要懂它,因此为了更好地发挥 Axios 在项目的价值,以及日后能够得心应手地使用它,笔者决定从源码层面好好欣赏一下它的美貌!

Axios是一款基于 Promise 并可用于浏览器和 Node.js 的网络请求库。

最近,Axios 官方文档终于变好看了,支持多语言切换,阅读更清晰,使用起来也更加舒适!作为一款受全球欢迎的网络请求库,有必要偷学一下其中的架构设计编码方式

本篇文章从源码层面主要分析 Axios 的功能实现设计模式、以及分享 Axios 中一些笔者认为比较“精彩”的地方!

本文主要内容结构如下,大家按需食用

一、Axios 项目概况

本次分析的 Axios 版本是:v0.24.0

通过简单的浏览 package.json、文件及目录,可以得知 axios 工程采用了如下三方依赖:

名称 说明
Grunt[1] JavaScript 任务运行器
dtslint[2] TypeScript 类型声明&样式校验工具
TypeScript[3] 支持TS环境下开发
Webpack[4] JavaScript 模块打包工具
karma[5] 测试用例检查器
mocha[6] 多功能的 JavaScript 测试框架
sinojs[7] 提供spies, stub, mock,推荐文章《Sinon 入门,看这篇文章就够了[8]
follow-redirects[9] http(s)重定向,NodeJS模块

这里省略了对一些工具介绍,但可以发现,Axios 开发项目的主功能依赖并不多,换句话说是只有 follow-redirects作为了“使用依赖”,其他都是编译、测试、框架层面的东西,可以看出官方团队在对于 Axios 有多么注质量和稳定性,毕竟是全球都在用的工具。

Axios 中相关代码都在 lib/ 目录下(建议逐行阅读):

.
├── adapters  // 网络请求,NodeJS 环境使用 NodeJS 的 http 模块,浏览器使用 XHR
│   ├── README.md
│   ├── http.js  // Node.js 环境使用
│   └── xhr.js  // 浏览器环境使用
├── helpers  // 一些功能辅助工具函数,看文件名可基本知道干啥的
│   ├── README.md
│   ├── bind.js
│   ├── buildURL.js
│   ├── combineURLs.js
│   ├── cookies.js
│   ├── deprecatedMethod.js
│   ├── isAbsoluteURL.js
│   ├── isAxiosError.js
│   ├── isURLSameOrigin.js
│   ├── normalizeHeaderName.js
│   ├── parseHeaders.js
│   ├── spread.js
│   └── validator.js
├── cancel  // 取消网络请求的处理
│   ├── Cancel.js  // 取消请求
│   ├── CancelToken.js  // 取消 Token
│   └── isCancel.js  // 判断是否取消请求的函数方法
├── core  // 核心功能
│   ├── Axios.js  // Axios 对象
│   ├── InterceptorManager.js  // 拦截器管理
│   ├── README.md
│   ├── buildFullPath.js  // 构造完成的请求 URL
│   ├── createError.js  // 创建错误,抛出异常
│   ├── dispatchRequest.js  // 请求分发,用于区分调用 http 还是 xhr
│   ├── enhanceError.js
│   ├── mergeConfig.js  // 合并配置参数
│   ├── settle.js  // 根据请求响应状态,改变 Promise 状态
│   └── transformData.js  // 数据格式转换
├── env  // 无关紧要,没啥用,与发包版本有关
│   ├── README.md
│   └── data.js
├── defaults.js  // 默认参数/初始化参数配置
├── utils.js  // 提供简单的通用的工具函数
└── axios.js  // 入口文件,初始化并导出 axios 对象

有了一个简单的代码功能组织架构熟悉后,对于串联 Axios 的功能很有好处,另外,从上述文件和文件夹的命名,很容易让人意识到这是一个什么功能的文件。

高内聚、低耦合”的真言,在 Axios 中应该算是一个运用得很好的例子。

二、Axios 网络请求流程图

梳理了一张 Axios 发起请求、响应请求的执行流程图,希望可以给大家一个完整流程的概念,便于理解后续的源码分析。

Axios 网络请求流程图
Axios 网络请求流程图

三、Axios API 设计

我们在使用 Axios 的时候,会觉得 Axios 的使用特别方便,其原因就是 Axios 中针对同一功能实现了不同的 API,便于大家在各种场景下的变通扩展使用。

例如,发起一个 GET 请求的写法有:

// 第一种
axios('https://xxx.com/api/userInfo?uid=1')

// 第二种
axios.get('https://xxx.com/api/userInfo?uid=1')

// 第三种
axios({
  method'GET',
  url'https://xxx.com/api/userInfo?uid=1'
})

Axios 请求的核心方法仅两种:

axios(config)
// or
axios(url[, config])

我们知道一个网络请求的方式会有 GET、POST、PUT、DELETE 等,为了使用更加语义化,Axios 对外暴露了别名 API:

axios.request(config)
axios.get(url[, config])
axios.delete(url[, config])
axios.head(url[, config])
axios.options(url[, config])

axios.post(url[, data[, config]])
axios.put(url[, data[, config]])
axios.patch(url[, data[, config]])

通过遍历扩展axios对象原型链上的方法:

// Provide aliases for supported request methods
utils.forEach(['delete''get''head''options'], function forEachMethodNoData(method{
  /*eslint func-names:0*/
  Axios.prototype[method] = function(url, config{
    return this.request(mergeConfig(config || {}, {
      method: method,
      url: url,
      data: (config || {}).data
    }));
  };
});

utils.forEach(['post''put''patch'], function forEachMethodWithData(method{
  /*eslint func-names:0*/
  Axios.prototype[method] = function(url, data, config{
    return this.request(mergeConfig(config || {}, {
      method: method,
      url: url,
      data: data
    }));
  };
});

能够如上的直接循环列表赋值,得益于 Axios 将核心的请求功能单独放到了 Axios.prototype.request 方法中,该方法的 TS 定义为:

Axios.request(config: any, ...args: any[]): any

在其方法(Axios.request())内会对外部传参数类型做判断,并选择组装正确的请求参数:

// 生成规范的 config,抹平 API(函数入参)差异
if (typeof config === 'string') {
  // 处理了第一个参数是 url 字符串的情况 request(url[, config])
  config = arguments[1] || {};
  config.url = arguments[0];
else {
  config = config || {};
}

// 合并默认配置
config = mergeConfig(this.defaults, config);

// 将请求方法转小写字母,默认为 get 方法
if (config.method) {
  config.method = config.method.toLowerCase();
else if (this.defaults.method) {
  config.method = this.defaults.method.toLowerCase();
else {
  config.method = 'get';
}

以此来抹平了各种类型请求以及所需传入参数之间的差异性!

四、Axios 工厂模式创建实例

默认 Axios 导出了一个单例,导出了一个实例化后的单例,所以我们可以直接引入后就可以调用 Axios 的方法。

在某些场景下,我们的项目中可能对接了多个业务方,那么请求中的 base URL 就不一样,因此有没有办法创建多个 Axios 实例?

那就是使用 axios.create([config]) 方法创建多个实例。

考虑到多实例这样的实际需求,Axios 对外暴露了 create() 方法,在 Axios 内部中,往导出的 axios 实例上绑定了用于创建本身实例的工厂方法:

/**
 * Create an instance of Axios
 *
 * @param {Object} defaultConfig The default config for the instance
 * @return {Axios} A new instance of Axios
 */

function createInstance(defaultConfig{
  var context = new Axios(defaultConfig);
  var instance = bind(Axios.prototype.request, context);

  // Copy axios.prototype to instance
  utils.extend(instance, Axios.prototype, context);

  // Copy context to instance
  utils.extend(instance, context);

  // Factory for creating new instances
  instance.create = function create(instanceConfig{
    return createInstance(mergeConfig(defaultConfig, instanceConfig));
  };

  return instance;
}

这里的实现值得一说的地方在于:

instance.create = function create(instanceConfig{
  return createInstance(mergeConfig(defaultConfig, instanceConfig));
};

在创建 axios 实例的工厂方法内,绑定工厂方法到实例的 create 属性上。为什么不是在工厂方法外绑定呐?这是我们可能的习惯做法,Axios 之前确实也是这么做的。

为什么挪到了内部?可以看看这条 PR: Allow axios.create(options) to be used recursively[10]

原因简单来说就是,用户自己创建的实例依然可以调用 create 方法创建新的实例,例如:

const axios = require('axios');

const jsonClient = axios.create({
  responseType'json' // 该项配置可以在后续创建的实例中复用,而不必重复编码
});

const serviceOne = jsonClient.create({
  baseURL'https://service.one/'
});

const serviceTwo = jsonClient.create({
  baseURL'https://service.two/'
});

这样有助于复用实例的公共参数,减少重复编码。

五、网络请求适配器

在文件 ./defaults.js 中生成了默认完整的 Request Config 参数。

其中 config.adapter 字段表明当前应该使用 ./adapters/目录下的 http.js 还是 xhr.js 模块

// 根据当前使用环境,选择使用的网络请求适配器
function getDefaultAdapter({
  var adapter;
  if (typeof XMLHttpRequest !== 'undefined') {
    // For browsers use XHR adapter
    adapter = require('./adapters/xhr');
  } else if (typeof process !== 'undefined' && Object.prototype.toString.call(process) === '[object process]') {
    // For node use HTTP adapter
    adapter = require('./adapters/http');
  }
  return adapter;
}

这里使用了设计模式中的适配器模式,通过判断不同环境下是否支持方法的方式,选择正确的网络请求模块,便可以实现官网所说的支持 NodeJS 和浏览器环境。

六、转换请求体和响应体数据

这是 Axios 贴在官网的核心功能之一,且提到了可以自动转换响应体内容为 JSON 数据

默认请求配置中初始化的请求/响应转换器数组
默认请求配置中初始化的请求/响应转换器数组
自动尝试转换响应数据为 JSON 格式
自动尝试转换响应数据为 JSON 格式

transformRequesttransformResponse 字段是一个数组类型,因此我们还可以向其中增加自定义的转换器。

一般来讲我们只会通过复写 transitional 字段来控制响应数据的转换与否,但可以作为扩展 Axios 的一个点,留了口子,这一点考虑得也很到位。

七、请求拦截器&响应拦截器

可以通过拦截器来提前处理请求前和收到响应前的一些处理方法。

7.1 拦截器的使用

拦截器用于在 .then().catch() 前注入并执行的一些方法。

// 通过 use 方法,添加一个请求拦截器
axios.interceptors.request.use(function (config{
    // 在发送请求前干点啥,.then() 处理之前,比如修改 request config
    return config;
  }, function (error{
    // 在发起请求发生错误后,.catch() 处理之前干点啥
    return Promise.reject(error);
  });

// 通过 use 方法,添加一个响应拦截器
axios.interceptors.response.use(function (response{
    // 只要响应网络状态码是 2xx 的都会触发
    // 干点啥
    return response;
  }, function (error{
    // 状态码不是 2xx 的会触发
    // 发生错误了,干点啥
    return Promise.reject(error);
  });

7.2 拦截管理器

Axios 将请求和响应的过程包装成了 Promise,那么 Axios 是如何实现拦截器在 .then().catch() 执行前执行呐?

可以很容易猜到通过组装一条 Promise 执行链即可!

来看看 Axios 在请求函数中如何实现:

首先是 Axios 对象中初始化了 拦截管理器:

function Axios(instanceConfig{
  this.defaults = instanceConfig;
  this.interceptors = {
    requestnew InterceptorManager(),
    responsenew InterceptorManager()
  };
}

来到 ./lib/core/InterceptorManager.js 文件下,对于拦截管理器

// 拦截管理器对象
function InterceptorManager({
  this.handlers = [];
}

/**
 * 添加新的管理器,定义了 use 方法
 *
 * @param {Function} fulfilled 处理 `Promise` 执行 `then` 的函数方法
 * @param {Function} rejected 处理 `Promise` 执行 `reject` 的函数方法
 *
 * @return {Number} 返回一个 ID 值用于移除拦截器
 */

InterceptorManager.prototype.use = function use(fulfilled, rejected, options{
  this.handlers.push({
    fulfilled: fulfilled,
    rejected: rejected,
    // 默认不同步
    synchronous: options ? options.synchronous : false,
    // 定义是否执行当前拦截器的函数或布尔值
    runWhen: options ? options.runWhen : null 
  });
  return this.handlers.length - 1// ID 值实际就是当前拦截器的数组索引
};

/**
 * 从栈中移除指定 id 的拦截器
 *
 * @param {Number} id use 方法返回的 id 值
 */

InterceptorManager.prototype.eject = function eject(id{
  if (this.handlers[id]) {
    this.handlers[id] = null// 删除拦截器,但索引会保留
  }
};

/**
 * 迭代所有注册的拦截器
 * 该方法会跳过因拦截器被删除而值为 null 的索引
 *
 * @param {Function} 调用每个有效拦截器的函数
 */

InterceptorManager.prototype.forEach = function forEach(fn{
  utils.forEach(this.handlers, function forEachHandler(h{
    if (h !== null) {
      fn(h);
    }
  });
};

迭代所有注册的拦截器是一个 FIFS(first come first served,先到先服务)队列执行顺序的方法。

7.3 组装拦截器与请求执行链

./lib/core/Axios.js 文件中,Axios 对象定义了 request 方法,其中将网络请求、请求拦截器和响应拦截器组装。

默认返回一个还未执行网络请求的 Promise 执行链,如果设置了同步,则会立即执行请求过程,并返回请求结果的 Promise 对象,也就是官方文档中提到的 Axios 还支持 Promise API。

函数详细的分析,都已经注释在如下代码中:

/**
 * Dispatch a request
 *
 * @param {Object} config 传入的用户自定义配置,并和默认配置 merge
 */

Axios.prototype.request = function request(config{
  // 省略 ...

  // 请求拦截器执行链
  var requestInterceptorChain = [];
  // 同步请求拦截器
  var synchronousRequestInterceptors = true;
  // 遍历请求拦截器
  this.interceptors.request.forEach(function unshiftRequestInterceptors(interceptor{
    // 判断 runWhen 如果是函数,则执行函数,结果若为 false,则不执行当前拦截器
    if (typeof interceptor.runWhen === 'function' && interceptor.runWhen(config) === false) {
      return;
    }
    // 判断当前拦截器是否同步
    synchronousRequestInterceptors = synchronousRequestInterceptors && interceptor.synchronous;
    // 插入 requestInterceptorChain 数组首位
    // 效果:[interceptor.fulfilled, interceptor.rejected, ...]
    requestInterceptorChain.unshift(interceptor.fulfilled, interceptor.rejected);
  });

  // 响应拦截器执行链
  var responseInterceptorChain = [];
  // 遍历所有的响应拦截器
  this.interceptors.response.forEach(function pushResponseInterceptors(interceptor{
    // 插入 responseInterceptorChain 尾部
    // 效果:[ ..., interceptor.fulfilled, interceptor.rejected]
    responseInterceptorChain.push(interceptor.fulfilled, interceptor.rejected);
  });

  var promise;

  // 如果非同步
  // 一般大家在使用 axios.interceptors.request.use 都没有传递第三个配置参数
  // 所以一般情况下会走这个逻辑
  if (!synchronousRequestInterceptors) {
    var chain = [dispatchRequest, undefined];
    // 将请求拦截器执行链放到 chain 数组头部
    Array.prototype.unshift.apply(chain, requestInterceptorChain);
    // 将响应拦截器执行链放到 chain 数组末尾
    chain = chain.concat(responseInterceptorChain);
    // 给 promise 赋值 Promise 对象,并注入 request config
    promise = Promise.resolve(config);
    // 循环 chain 数组,组合成 Promise 执行链
    while (chain.length) {
      // 正好 resolve 和 reject 对应方法,两两一组
      promise = promise.then(chain.shift(), chain.shift());
    }
    // 返回 Promise 执行链
    return promise;
  }

  // 同步方式
  var newConfig = config;
  // 循环并执行所有请求拦截器
  while (requestInterceptorChain.length) {
    var onFulfilled = requestInterceptorChain.shift();
    var onRejected = requestInterceptorChain.shift();
    try {
      // 执行定义请求前的“请求拦截器” then 处理方法
      newConfig = onFulfilled(newConfig);
    } catch (error) {
      // 执行定义请求前的“请求拦截器” catch 处理方法
      onRejected(error);
      break;
    }
  }

  try {
    // 执行网络请求
    promise = dispatchRequest(newConfig);
  } catch (error) {
    return Promise.reject(error);
  }

  // 循环并执行所有响应拦截器
  while (responseInterceptorChain.length) {
    promise = promise.then(responseInterceptorChain.shift(), responseInterceptorChain.shift());
  }
  // 返回 Promise 对象
  return promise;
};

可以看到由于请求拦截器和响应拦截器使用了 unshiftpush,那么 use 拦截器的先后顺序就有变动。

通过如上代码的分析,可以得知若有多个拦截器的执行顺序规则是:

  • 请求拦截器:先 use,后执行
  • 响应拦截器:先 use,先执行

关于拦截器执行这部分,涉及到一个 PR改动: Requests unexpectedly delayed due to an axios internal promise[11],推荐大家阅读一下,有助于熟悉微任务宏任务

改动的原因:如果请求拦截器中存在一些长时间的任务,会使得使用 axios 的网络请相较于不使用 axios 的网络请求会延后,为此,通过为拦截管理器增加 synchronousrunWhen 字段,来实现同步执行请求方法。

八、取消网络请求

在网络请求中,会遇到许多非预期的请求取消,当然也有主动取消请求的时候,例如,用户获取 id=1 的新闻数据,需要耗时 30s,用户等不及了,就返回查看 id=2 的新闻详情,此时我们可以在代码中主动取消 id=1 的网络请求,节省网络资源。

8.1 如何取消 Axios 请求

通过 CancleToken.source() 工厂方法创建取消请求的实例 source

在发起请求的 request Config 中设置 cancelToken 值为 source.token

在需要主动取消请求的地方调用:source.cancle()

const CancelToken = axios.CancelToken;
const source = CancelToken.source();

axios.get('/user/12345', {
  cancelToken: source.token
}).catch(function (thrown{
  if (axios.isCancel(thrown)) {
    console.log('Request canceled', thrown.message);
  } else {
    // handle error
  }
});

axios.post('/user/12345', {
  name'new name'
}, {
  cancelToken: source.token
})

// 主动取消请求 (提示信息是可选的参数)
source.cancel('Operation canceled by the user.');

同一个 source 实例调用取消 cancle() 方法时,会取消所有含有当前实例 source.token 的请求

8.2 取消请求功能的原理

想必大家也很好奇是怎么实现取消网络请求功能的,实际上有了上述的基础,把 Axios 的请求想象成为一条事件执行链,执行链中任意一处发生了异常,都会中断整个请求。

整个请求执行链中的设计了,首先来看:axios.CancelToken.source()

/**
 * Returns an object that contains a new `CancelToken` and a function that, when called,
 * cancels the `CancelToken`.
 */

CancelToken.source = function source({
  var cancel;
  var token = new CancelToken(function executor(c{
    cancel = c;
  });
  return {
    token: token,
    cancel: cancel
  };
};

该工厂方法返回了一个对象,该对象包含了一个 token(取消令牌,CancleToken 对象的实例),以及一个取消与 token 映射绑定的取消请求方法 cancle()

其中 new CancelToken() 会创建 CancleToken 的单例,通过传入函数方式,拿到了取消请求的回调函数,该函数内会构造 token 取消的原因,并通过执行 resolvePromise(),主动 reslove。

同样是一个微任务,当主动调用 cancle() 方法后,会调用 resolvePromise(reason),此时就会给当前 cancleToken 实例的 reason 字段赋值“请求取消的原因”:

function CancelToken(executor{
  if (typeof executor !== 'function') {
    throw new TypeError('executor must be a function.');
  }

  // 初始化一个 promise 属性,resolvePromise 变量指向 resolve
  var resolvePromise;
  this.promise = new Promise(function promiseExecutor(resolve{
    resolvePromise = resolve;
  });

  // 赋值 token 为当前对象的实例
  var token = this;

  // 省略...

  // 执行外部传入的初始化方法,将取消请求的方法,赋值给返回对象的 cancel 属性
  executor(function cancel(message{
    if (token.reason) {
      // Cancellation has already been requested
      return;
    }

    token.reason = new Cancel(message);
    resolvePromise(token.reason);
  });
}

./lib/core/dispatchRequest.js 文件中:

function throwIfCancellationRequested(config{
  // 当 request config 中有实例化 cancelToken 时
  // 执行 throwIfRequested() 方法
  // throwIfRequested() 方法在 cancleToken 实例的 reason 字段有值时
  // 抛出异常
  if (config.cancelToken) {
    config.cancelToken.throwIfRequested();
  }
  // 判断 config.signal.aborted 值为真的时候抛出异常
  // 该值时通过 new AbortController().signal,不过目前暂时未用到
  // 官方文档上暂也暂未更新相关内容
  if (config.signal && config.signal.aborted) {
    throw new Cancel('canceled');
  }
}

module.exports = function dispatchRequest(config{
  // 准备发起请求前检查
  throwIfCancellationRequested(config);
  
  // 省略...
  
  var adapter = config.adapter || defaults.adapter;
  return adapter(config).then(function onAdapterResolution(response{
    // 请求成功后检查
    throwIfCancellationRequested(config);
    // 省略...
    return response;
  }, function onAdapterRejection(reason{
    if (!isCancel(reason)) {
      // 请求发生错误时候检查
      throwIfCancellationRequested(config);
      // 省略...
    }
    // 省略...

    return Promise.reject(reason);
  });
}

在文章前边分析拦截器的时候讲到了 dispatchRequest() 在请求拦截器之后执行。

在请求前,请求成功、失败后三个时机点,都会通过 throwIfCancellationRequested() 函数检查是否取消了请求,throwIfCancellationRequested() 函数判断了 cancleToken.reason 是否有值,如果有则抛出异常并中断请求 Promise 执行链。

九、CSRF 防御

Axios 支持防御 CSRF(Cross-site request forgery,跨站请求伪造)攻击,而防御 CSRF 攻击的最简单方式就是加 Token。

CSRF 的攻击可以简述为:服务器错把攻击者的请求当成了正常用户的请求。

加一个 Token 为什么就能解决呐?首先 Token 是服务端随用户每次请求动态生成下发的,用户在提交表单、查询数据等行为的时候,需要在网络请求体加上这个临时性的 Token 值,攻击者无法在三方网站中获取当前 Token,因此服务端就可以通过验证 Token 来区分是否是正常用户的请求。

Axios 在请求配置中提供了两个字段:

// cookie 中携带的 Token 名称,通过该名称可以从 cookie 中拿到 Token 值
xsrfCookieName'XSRF-TOKEN',
// 请求 Header 中携带的 Token 名称,通过该成名可从 Header 中拿到 Token 值
xsrfHeaderName'X-XSRF-TOKEN',

用于附加验证防御 CSRF 攻击的 Token。

十、值得一说的自定义工具库

在 Axios 内,没有引入其他例如 lodash 的工具函数依赖,都在自己内部按需实现了工具函数,提供给整个项目使用。

个人非常喜欢这种做法,尤其是在一个 ES5 的工具库下,这样做不仅代码易读,与此同时还显得非常得纯粹、干净、清晰!

如果团队内有这种诉求,建议可以写一个 ESM 模块的工具库,这样做以后,在打包 Tree Shaking 时,打包的结果应该能更加干净。

总结

总体来说,Axios 涉及到的设计模式就有:单例模式、工厂模式、职责链模式、适配器模式,因此绝对是值得学习的一个工具库,梳理之后不仅利于我们灵活使用其 API,更有助于根据业务去自定义扩展封装网络请求,将网络请求统一收口。

与此同时,Axios 绝对是一个可以作为软件工程编码的学习范本,其中的文件夹结构,功能设计,功能解耦,按需封装工具类,以及灵活运用设计模式都是值得揣度回味。

相信能看到文末的你,一定收获不小,不妨动动小手,点个关注?

参考资料

[1]

Grunt: https://gruntjs.com/

[2]

dtslint: https://www.npmjs.com/package/dtslint

[3]

TypeScript: https://github.com/Microsoft/TypeScript

[4]

Webpack: https://www.webpackjs.com/

[5]

karma: https://github.com/crazygit/karma-intro

[6]

Mocha: https://mochajs.org/

[7]

SINON.JS: https://sinonjs.org/

[8]

Sinon 入门,看这篇文章就够了: https://segmentfault.com/a/1190000010372634

[9]

follow-redirects: https://github.com/follow-redirects/follow-redirects

[10]

Allow axios.create(options) to be used recursively: https://github.com/axios/axios/pull/2795

[11]

Requests unexpectedly delayed due to an axios internal promise: https://github.com/axios/axios/issues/2609

DYBOY

2021/11/19  阅读:31  主题:自定义主题1

作者介绍

DYBOY