axios 源碼深入分析之 源碼實現分析

一、源碼實現分析

  1. axios 源碼目錄結構,如下所示:

    ├── /dist/                     # 項目輸出目錄
    ├── /lib/                      # 項目源碼目錄
    │ ├── /adapters/               # 定義請求的適配器 xhr、http
    │ │ ├── http.js                # 實現http適配器(包裝http包)
    │ │ └── xhr.js                 # 實現xhr適配器(包裝xhr對象)
    │ ├── /cancel/                 # 定義取消功能
    │ ├── /core/                   # 一些核心功能
    │ │ ├── Axios.js               # axios的核心主類
    │ │ ├── dispatchRequest.js     # 用來調用http請求適配器方法發送請求的函數
    │ │ ├── InterceptorManager.js  # 攔截器的管理器
    │ │ └── settle.js              # 根據http響應狀態,改變Promise的狀態
    │ ├── /helpers/                # 一些輔助方法
    │ ├── axios.js                 # 對外暴露接口
    │ ├── defaults.js              # axios的默認配置 
    │ └── utils.js                 # 公用工具
    ├── package.json               # 項目信息
    ├── index.d.ts                 # 配置TypeScript的聲明文件
    └── index.js                   # 入口文件
    
  2. axiosAxios 的關係,如下所示:

  • 從語法上來說: axios 不是 Axios 的實例
  • 從功能上來說: axiosAxios 的實例
  • axios 函數對應的是 Axios.prototype.request 方法通過 bind(Axiox的實例) 產生的函數
  • axios 作爲對象有 Axios 原型對象上的所有方法, 有 Axios 對象上所有屬性
  • axiosAxios 原型上的所有發特定類型請求的方法: get()/post()/put()/delete()
  • axiosAxios 的實例上的所有屬性:defaults/interceptors,後面又添加了 create()/CancelToken()/all()
  1. instanceaxios 的區別? 如下所示:
  • 相同點:
    • 都是一個能發任意請求的函數: request(config)
    • 都有發特定請求的各種方法: get()/post()/put()/delete()
    • 都有默認配置和攔截器的屬性: defaults/interceptors
  • 不同點:
    • 默認匹配的值很可能不一樣
    • instance 沒有 axios 後面添加的一些方法: create()/CancelToken()/all()
  1. 看一下 axios 的核心源碼,代碼如下所示:
'use strict';

var utils = require('./../utils');
var buildURL = require('../helpers/buildURL');
var InterceptorManager = require('./InterceptorManager');
var dispatchRequest = require('./dispatchRequest');
var mergeConfig = require('./mergeConfig');

/**
 * Axios構造函數
 * Create a new instance of Axios
 * @param {Object} instanceConfig The default config for the instance
 */
function Axios(instanceConfig) {
  // 將指定的config, 保存爲defaults屬性
  this.defaults = instanceConfig;
  // 將包含請求/響應攔截器管理器的對象保存爲interceptors屬性
  this.interceptors = {
    request: new InterceptorManager(),
    response: new InterceptorManager()
  };
}

/**
 * 用於發請求的函數
 * 我們使用的axios就是此函數bind()返回的函數
 * 
 * Dispatch a request
 *
 * @param {Object} config The config specific for this request (merged with this.defaults)
 */
Axios.prototype.request = function request(config) {
  /*eslint no-param-reassign:0*/
  // Allow for axios('example/url'[, config]) a la fetch API
  if (typeof config === 'string') {
    config = arguments[1] || {};
    config.url = arguments[0];
  } else {
    config = config || {};
  }

  // 合併配置
  config = mergeConfig(this.defaults, config);
  // 添加method配置, 默認爲get
  config.method = config.method ? config.method.toLowerCase() : 'get';

  /*
  創建用於保存請求/響應攔截函數的數組
  數組的中間放發送請求的函數
  數組的左邊放請求攔截器函數(成功/失敗)
  數組的右邊放響應攔截器函數
  */
  var chain = [dispatchRequest, undefined];
  var promise = Promise.resolve(config);

  // 後添加的請求攔截器保存在數組的前面
  this.interceptors.request.forEach(function unshiftRequestInterceptors(interceptor) {
    chain.unshift(interceptor.fulfilled, interceptor.rejected);
  });
  // 後添加的響應攔截器保存在數組的後面
  this.interceptors.response.forEach(function pushResponseInterceptors(interceptor) {
    chain.push(interceptor.fulfilled, interceptor.rejected);
  });
  

  // 通過promise的then()串連起所有的請求攔截器/請求方法/響應攔截器
  while (chain.length) {
    promise = promise.then(chain.shift(), chain.shift());
  }

  // 返回用來指定我們的onResolved和onRejected的promise
  return promise;
};

// 用來得到帶query參數的url
Axios.prototype.getUri = function getUri(config) {
  config = mergeConfig(this.defaults, config);
  return buildURL(config.url, config.params, config.paramsSerializer).replace(/^\?/, '');
};

// 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(utils.merge(config || {}, {
      method: method,
      url: url
    }));
  };
});

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

module.exports = Axios;

  1. axios 運行的整體流程,如下所示:
  • 整體流程: request(config) ===> dispatchRequest(config) ===> xhrAdapter(config)
  • request(config): 將請求攔截器 / dispatchRequest() / 響應攔截器 通過 promise 鏈串連起來, 返回 promise
  • dispatchRequest(config): 轉換請求數據 ===> 調用 xhrAdapter() 發請求 ===> 請求返回後轉換響應數據. 返回 promise
  • xhrAdapter(config) : 創建 XHR 對象, 根據 config 進行相應設置, 發送特定請求, 並接收響應數據, 返回 promise
  1. 對於 axios 的流程,也可以這麼理解:axiosaxios.create() 一起發送請求,被 createInstance() 一起接收,配合 config 執行/別名執行,Axios.prototype.request 執行,然後執行 request.interceptors,配合處理參數與默認參數/transformdata 執行 dispatchRequest,然後執行 adapter。如果報錯或者是 cancel 取消,那麼執行 axios.rejected。如果正確,那麼執行 axios fulfilledresponse interceptors 執行,最後請求的 onResolved 或者是 onRejected

  2. 對於 request 的流程,如下所示:

    requestInterceptors: [{fulfilled1(){}, rejected1(){}}, {fulfilled2(){}, rejected2(){}}]
        responseInterceptors: [{fulfilled11(){}, rejected11(){}}, {fulfilled22(){}, rejected22(){}}]
        chain: [
          fulfilled2, rejected2, fulfilled1, rejected1, 
          dispatchReqeust, undefined, 
          fulfilled11, rejected11, fulfilled22, rejected22
        ]
        promise鏈回調: config 
                      => (fulfilled2, rejected2) => (fulfilled1, rejected1)   // 請求攔截器處理
                      => (dispatchReqeust, undefined) // 發請求
                      => (fulfilled11, rejected11) => (fulfilled22, rejected22) // 響應攔截器處理
                      => (onResolved, onRejected) // axios發請求回調處理
    
  3. 對於 dispatchRequest 的流程,看下源碼如下所示:

  • dispatchRequest.js :

     'use strict';
    
     var utils = require('./../utils');
     var transformData = require('./transformData');
     var isCancel = require('../cancel/isCancel');
     var defaults = require('../defaults');
     var isAbsoluteURL = require('./../helpers/isAbsoluteURL');
     var combineURLs = require('./../helpers/combineURLs');
     
     /**
      * Throws a `Cancel` if cancellation has been requested.
      */
     function throwIfCancellationRequested(config) {
       if (config.cancelToken) {
         config.cancelToken.throwIfRequested();
       }
     }
     
     /**
      * Dispatch a request to the server using the configured adapter.
      *
      * @param {object} config The config that is to be used for the request
      * @returns {Promise} The Promise to be fulfilled
      */
     module.exports = function dispatchRequest(config) {
     
       /* 
       如果請求已經被取消, 直接拋出異常
       */
       throwIfCancellationRequested(config);
     
       /* 
       合併config中的baseURL和url
       */
       if (config.baseURL && !isAbsoluteURL(config.url)) {
         config.url = combineURLs(config.baseURL, config.url);
       }
     
       // Ensure headers exist
       config.headers = config.headers || {};
     
       /* 
       對config中的data進行必要的轉換處理
       設置相應的Content-Type請求頭
       */
       config.data = transformData(
         config.data,
         config.headers,
         config.transformRequest
       );
     
       /* 
       整合config中所有的header
       */
       config.headers = utils.merge(
         config.headers.common || {},
         config.headers[config.method] || {},
         config.headers || {}
       );
     
       utils.forEach(
         ['delete', 'get', 'head', 'post', 'put', 'patch', 'common'],
         function cleanHeaderConfig(method) {
           delete config.headers[method];
         }
       );
     
       var adapter = config.adapter || defaults.adapter;
     
       return adapter(config).then(function onAdapterResolution(response) {
         throwIfCancellationRequested(config);
     
         /* 
         對response中還沒有解析的data數據進行解析
         json字符串解析爲js對象/數組
         */
         response.data = transformData(
           response.data,
           response.headers,
           config.transformResponse
         );
     
         return response;
       }, function onAdapterRejection(reason) {
         if (!isCancel(reason)) {
           throwIfCancellationRequested(config);
     
           // Transform response data
           if (reason && reason.response) {
             reason.response.data = transformData(
               reason.response.data,
               reason.response.headers,
               config.transformResponse
             );
           }
         }
     
         return Promise.reject(reason);
       });
     };
    
     ```
    - **InterceptorManager.js** :
     ```js
     'use strict';
    
     var utils = require('./../utils');
     
     function InterceptorManager() {
       // 用來保存攔截器函數的數組, 數組中每個都是對象, 對象中包含fulfilled/rejected方法
       this.handlers = [];
     }
     
     /**
      * Add a new interceptor to the stack
      *
      * @param {Function} fulfilled The function to handle `then` for a `Promise`
      * @param {Function} rejected The function to handle `reject` for a `Promise`
      *
      * @return {Number} An ID used to remove interceptor later
      */
     InterceptorManager.prototype.use = function use(fulfilled, rejected) {
       // 添加成功和失敗的攔截器函數
       this.handlers.push({
         fulfilled: fulfilled,
         rejected: rejected
       });
       // 返回攔截器對應的ID(也就是下標)
       return this.handlers.length - 1;
     };
     
     /**
      * Remove an interceptor from the stack
      *
      * @param {Number} id The ID that was returned by `use`
      */
     InterceptorManager.prototype.eject = function eject(id) {
       // 移除指定id對應的攔截器
       if (this.handlers[id]) {
         this.handlers[id] = null;
       }
     };
     
     /**
      * Iterate over all the registered interceptors
      *
      * This method is particularly useful for skipping over any
      * interceptors that may have become `null` calling `eject`.
      *
      * @param {Function} fn The function to call for each interceptor
      */
     InterceptorManager.prototype.forEach = function forEach(fn) {
       // 遍歷處理所有保存的攔截器
       utils.forEach(this.handlers, function forEachHandler(h) {
         if (h !== null) {
           fn(h);
         }
       });
     };
     
     module.exports = InterceptorManager;
    
     ```
    - **defaults.js** :
     ```js
     'use strict';
    
     var utils = require('./utils');
     var normalizeHeaderName = require('./helpers/normalizeHeaderName');
     
     // 默認的Content-Type頭的值
     var DEFAULT_CONTENT_TYPE = {
       'Content-Type': 'application/x-www-form-urlencoded'
     };
     
     function setContentTypeIfUnset(headers, value) {
       if (!utils.isUndefined(headers) && utils.isUndefined(headers['Content-Type'])) {
         headers['Content-Type'] = value;
       }
     }
     
     function getDefaultAdapter() {
       var adapter;
       // Only Node.JS has a process variable that is of [[Class]] process
       if (typeof process !== 'undefined' && Object.prototype.toString.call(process) === '[object process]') {
         // For node use HTTP adapter
         adapter = require('./adapters/http');
       } else if (typeof XMLHttpRequest !== 'undefined') {
         // For browsers use XHR adapter
         adapter = require('./adapters/xhr');
       }
       return adapter;
     }
     
     var defaults = {
       // 得到當前環境對應的請求適配器
       adapter: getDefaultAdapter(),
     
       // 請求轉換器
       transformRequest: [function transformRequest(data, headers) {
         // 指定headers中更規範的請求頭屬性名
         normalizeHeaderName(headers, 'Accept');
         normalizeHeaderName(headers, 'Content-Type');
     
     
         if (utils.isFormData(data) ||
           utils.isArrayBuffer(data) ||
           utils.isBuffer(data) ||
           utils.isStream(data) ||
           utils.isFile(data) ||
           utils.isBlob(data)
         ) {
           return data;
         }
         if (utils.isArrayBufferView(data)) {
           return data.buffer;
         }
         if (utils.isURLSearchParams(data)) {
           setContentTypeIfUnset(headers, 'application/x-www-form-urlencoded;charset=utf-8');
           return data.toString();
         }
         // 如果data是對象, 指定請求體參數格式爲json, 並將參數數據對象轉換爲json
         if (utils.isObject(data)) {
           setContentTypeIfUnset(headers, 'application/json;charset=utf-8');
           return JSON.stringify(data);
         }
         return data;
       }],
     
       // 響應數據轉換器: 解析字符串類型的data數據
       transformResponse: [function transformResponse(data) {
         /*eslint no-param-reassign:0*/
         if (typeof data === 'string') {
           try {
             data = JSON.parse(data);
           } catch (e) { /* Ignore */ }
         }
         return data;
       }],
     
       /**
        * A timeout in milliseconds to abort a request. If set to 0 (default) a
        * timeout is not created.
        */
       timeout: 0,
     
       xsrfCookieName: 'XSRF-TOKEN',
       xsrfHeaderName: 'X-XSRF-TOKEN',
     
       maxContentLength: -1,
     
       // 判斷響應狀態碼的合法性: [200, 299]
       validateStatus: function validateStatus(status) {
         return status >= 200 && status < 300;
       }
     };
     
     defaults.headers = {
       // 包含所有通用的請求的對象
       common: {
         'Accept': 'application/json, text/plain, */*'
       }
     };
     
     // 指定delete/get/head請求方式的請求頭容器對象
     utils.forEach(['delete', 'get', 'head'], function forEachMethodNoData(method) {
       defaults.headers[method] = {};
     });
     
     // 指定post/put/patch請求方式的請求頭容器對象
     utils.forEach(['post', 'put', 'patch'], function forEachMethodWithData(method) {
       // 指定了默認的Content-Type頭
       defaults.headers[method] = utils.merge(DEFAULT_CONTENT_TYPE);
     });
     
     module.exports = defaults;
    
     ```
    
    
  1. axios 的請求/響應攔截器是什麼? 如下所示:
  • 請求攔截器: 在真正發請求前,可以對請求進行檢查或配置進行特定處理的函數, 包括成功/失敗的函數, 傳遞的必須是 config。失敗的回調函數, 傳遞的默認是 error
  • 響應攔截器: 在請求返回後,可以對響應數據進行特定處理的函數,包括成功/失敗的函數,傳遞的默認是 response。失敗的回調函數, 傳遞的默認是 error
  1. axios 的請求/響應數據轉換器是什麼? 如下所示:
  • 請求轉換器:對請求頭和請求體數據進行特定處理的函數
    setContentTypeIfUnset(headers, 'application/json;charset=utf-8'); return JSON.stringify(data),如下:

    if (utils.isObject(data)) {
        setContentTypeIfUnset(headers, 'application/json;charset=utf-8'); 
        return JSON.stringify(data); 
    }
    
  • 響應轉換器: 將響應體 json 字符串解析爲 js 對象或數組的函數
    response.data = JSON.parse(response.data)

  1. 對於 xhrAdapter 的流程,看下 xhr.js 的源碼,如下所示:
'use strict';

var utils = require('./../utils');
var settle = require('./../core/settle');
var buildURL = require('./../helpers/buildURL');
var parseHeaders = require('./../helpers/parseHeaders');
var isURLSameOrigin = require('./../helpers/isURLSameOrigin');
var createError = require('../core/createError');

module.exports = function xhrAdapter(config) {
  // 返回一個promise
  return new Promise(function dispatchXhrRequest(resolve, reject) {
    
    var requestData = config.data;
    var requestHeaders = config.headers;

    if (utils.isFormData(requestData)) {
      delete requestHeaders['Content-Type']; // Let the browser set it
    }

    // 創建XHR對象
    var request = new XMLHttpRequest();

    // HTTP basic authentication
    if (config.auth) {
      var username = config.auth.username || '';
      var password = config.auth.password || '';
      requestHeaders.Authorization = 'Basic ' + btoa(username + ':' + password);
    }

    // 初始化請求
    request.open(config.method.toUpperCase(), buildURL(config.url, config.params, config.paramsSerializer), true);

    // 指定超時時間(單位ms)
    request.timeout = config.timeout;

    // 綁定請求狀態改變的監聽
    request.onreadystatechange = function handleLoad() {
      // request不存在或請求狀態不是4, 直接結束
      if (!request || request.readyState !== 4) {
        return;
      }

      // The request errored out and we didn't get a response, this will be
      // handled by onerror instead
      // With one exception: request that using file: protocol, most browsers
      // will return status as 0 even though it's a successful request
      if (request.status === 0 && !(request.responseURL && request.responseURL.indexOf('file:') === 0)) {
        return;
      }

      // 準備response對象
      var responseHeaders = 'getAllResponseHeaders' in request ? parseHeaders(request.getAllResponseHeaders()) : null;
      var responseData = !config.responseType || config.responseType === 'text' ? request.responseText : request.response;
      var response = {
        data: responseData,
        status: request.status,
        statusText: request.statusText,
        headers: responseHeaders,
        config: config,
        request: request
      };

      // 根據響應狀態碼來確定請求的promise的結果狀態(成功/失敗)
      settle(resolve, reject, response);

      // 將請求對象賦空
      request = null;
    };

    // 綁定請求中斷監聽
    request.onabort = function handleAbort() {
      if (!request) {
        return;
      }
      // reject promise, 指定aborted的error
      reject(createError('Request aborted', config, 'ECONNABORTED', request));

      // Clean up request
      request = null;
    };

    // Handle low level network errors
    request.onerror = function handleError() {
      // Real errors are hidden from us by the browser
      // onerror should only fire if it's a network error
      reject(createError('Network Error', config, null, request));

      // Clean up request
      request = null;
    };

    // Handle timeout
    request.ontimeout = function handleTimeout() {
      reject(createError('timeout of ' + config.timeout + 'ms exceeded', config, 'ECONNABORTED',
        request));

      // Clean up request
      request = null;
    };

    // Add xsrf header
    // This is only done if running in a standard browser environment.
    // Specifically not if we're in a web worker, or react-native.
    if (utils.isStandardBrowserEnv()) {
      var cookies = require('./../helpers/cookies');

      // Add xsrf header
      var xsrfValue = (config.withCredentials || isURLSameOrigin(config.url)) && config.xsrfCookieName ?
        cookies.read(config.xsrfCookieName) :
        undefined;

      if (xsrfValue) {
        requestHeaders[config.xsrfHeaderName] = xsrfValue;
      }
    }

    // 如果沒有指定請求體參數, 刪除Content-Type請求頭, 其它所有請求頭都設置到request上
    // Add headers to the request
    if ('setRequestHeader' in request) {
      utils.forEach(requestHeaders, function setRequestHeader(val, key) {
        if (typeof requestData === 'undefined' && key.toLowerCase() === 'content-type') {
          // Remove Content-Type if data is undefined
          delete requestHeaders[key];
        } else {
          // Otherwise add header to the request
          request.setRequestHeader(key, val);
        }
      });
    }

    // Add withCredentials to request if needed
    if (config.withCredentials) {
      request.withCredentials = true;
    }

    // 如果需要指定responseType
    // Add responseType to request if needed
    if (config.responseType) {
      try {
        request.responseType = config.responseType;
      } catch (e) {
        // Expected DOMException thrown by browsers not compatible XMLHttpRequest Level 2.
        // But, this can be suppressed for 'json' type as it can be parsed by default 'transformResponse' function.
        if (config.responseType !== 'json') {
          throw e;
        }
      }
    }

    // 綁定下載進度的監聽
    // Handle progress if needed
    if (typeof config.onDownloadProgress === 'function') {
      request.addEventListener('progress', config.onDownloadProgress);
    }

    // 綁定上傳進度的監聽
    // Not all browsers support upload events
    if (typeof config.onUploadProgress === 'function' && request.upload) {
      request.upload.addEventListener('progress', config.onUploadProgress);
    }

    // 如果配置了cancelToken
    if (config.cancelToken) {
      // 指定用於中斷請求的回調函數
      config.cancelToken.promise.then(function onCanceled(cancel) {
        if (!request) {
          return;
        }
        // 中斷請求
        request.abort();
        // 讓請求的promise失敗
        reject(cancel);
        // Clean up request
        request = null;
      });
    }

    if (requestData === undefined) {
      requestData = null;
    }

    // 發送請求, 指定請求體數據, 可能是null
    request.send(requestData);
  });
};

  1. response 的整體結構,如下所示:

        {
            data,
            status,
            statusText,
            headers,
            config,
            request
        }
    
  2. error 的整體結構,如下所示:

        {
            message,
            request,
            response
        }
    
  3. 對於 cancel 取消的流程,看下源碼如下所示:

    • Cancel.js :
      'use strict';
      
      /**
       * 當取消一個請求時, 需要將Cancel對象作爲一個error拋出
       * 
       * A `Cancel` is an object that is thrown when an operation is canceled.
       *
       * @class
       * @param {string=} message The message.
       */
      function Cancel(message) {
        this.message = message;
      }
      
      Cancel.prototype.toString = function toString() {
        return 'Cancel' + (this.message ? ': ' + this.message : '');
      };
      
      // 用於標識是一個取消的error
      Cancel.prototype.__CANCEL__ = true;
      
      module.exports = Cancel;
      
      
    • isCancel.js :
      'use strict';
      /* 
      用於判斷一個error是不是一個cancel錯誤
      */
      module.exports = function isCancel(value) {
        return !!(value && value.__CANCEL__);
      };
      
      
    • CancelToken.js :
      'use strict';
      
      var Cancel = require('./Cancel');
      
      /**
       * 用於取消請求的對象構造函數
       * 
       * A `CancelToken` is an object that can be used to request cancellation of an operation.
       *
       * @class
       * @param {Function} executor The executor function.
       */
      function CancelToken(executor) {
        if (typeof executor !== 'function') {
          throw new TypeError('executor must be a function.');
        }
      
        // 爲取消請求準備一個promise對象, 並保存resolve函數
        var resolvePromise;
        this.promise = new Promise(function promiseExecutor(resolve) {
          resolvePromise = resolve;
        });
      
        // 保存當前token對象
        var token = this;
      
        // 立即執行接收的執行器函數, 並傳入用於取消請求的cancel函數
        executor(function cancel(message) {
          // 如果token中有reason了, 說明請求已取消
          if (token.reason) {
            // Cancellation has already been requested
            return;
          }
          // 將token的reason指定爲一個Cancel對象
          token.reason = new Cancel(message);
          // 將取消請求的promise指定爲成功, 值爲reason
          resolvePromise(token.reason);
        });
      }
      
      /**
       * 如果請求已經被取消, 拋出reason也就是Cancel對象的異常
       * Throws a `Cancel` if cancellation has been requested.
       */
      CancelToken.prototype.throwIfRequested = function throwIfRequested() {
        if (this.reason) {
          throw this.reason;
        }
      };
      
      /**
       * 創建一個包含token對象和cancel函數的對象, 並添加給CancelToken
       * 
       * 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
        };
      };
      
      module.exports = CancelToken;
      
      
  4. 如何取消未完成的請求,如下所示:

  • 當配置了 cancelToken 對象時,保存 cancel 函數,如下:
    • 創建一個用於將來中斷請求的 cancelPromise
    • 並定義了一個用於取消請求的 cancel 函數
    • cancel 函數傳遞出來
  • 調用 cancel() 取消請求,如下:
    • 執行 cacel 函數, 傳入錯誤信息 message
    • 內部會讓 cancelPromise 變爲成功, 且成功的值爲一個 Cancel 對象
    • cancelPromise 的成功回調中中斷請求, 並讓發請求的 proimse 失敗, 失敗的 reasonCacel 對象
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章