【大厂企业级项目架构】之网络请求封装和接口管理

1,552 阅读10分钟

携手创作,共同成长!这是我参与「掘金日新计划 · 8 月更文挑战」的第9天,点击查看活动详情

这是大厂企业级项目架构系列的第五篇,这系列的文章如下:

网络请求也是现在的项目中必不可少的,只要不是玩具代码,基本都需要发送网络请求。本篇文章我们讲解如何基Axios封装我们项目的网络请求工具,如何管理我们的接口,使得后续我们发送网络请求更加简单易用易维护。

架构图

首先明确一下我们本次封装需要做的事:

  • 先基于axios封装一个Request请求工具类,这个类支持扩展不同的拦截器
  • 通过拦截器去支持请求参数处理,包括参数转换/序列化,支持文件上传
  • 通过拦截器支持取消重复请求
  • 通过响应错误拦截器支持支持请求重试
  • 支持统一的错误码处理
  • 支持接口统一的loading/报错提示等
  • 接口统一管理,有良好的接口字段提示等

axios简单使用

首先,我们从axios最简单的用法开始讲起,通常我们如果什么都不封装,就会像如下一样直接使用:

axios.create({})
     .request({ url: 'xxx', method: 'POST', data: {}})
     .then(res => {
         // 拿到请求结果
     });

这么写倒不是说有什么问题,只不过就不利于管理维护了。

比如说接口异常了,这么写就得每个地方都catch一下,而其实我们希望可以有统一错误处理,然后业务层再根据自己的特定需要去catch,如果不catch,就走默认的统一错误处理。

再比如说要做接口的loading,不统一管理的话,就得每个地方的请求都自己去触发loading和关掉loading。

基于上面的种种需求,所以说统一的请求封装处理是很有必要的。

axios封装

封装Request工具类

首先,我们写一个叫Request的工具类来保存公共的请求配置和axios实例

export default class Request {
    // 保存axios实例
    private axiosInstance: AxiosInstance;
    // 保存请求公共配置,所有Request实例的请求都共用的这个配置
    private readonly options: RequestOptions;
    constructor(options: RequestOptions) {
        this.options = options;
        this.axiosInstance = axios.create(options);
    }
    // 提供一个方法可以修改当前保存的axios实例
    setAxios(config: RequestOptions): void {
        this.axiosInstance = axios.create(config);
    }
    getAxios() {
        return this.axiosInstance;
    }
    // 真正的请求方法,其实还是调的axios的request
    // curRequestOptions是每个请求都可以有自己配置的options,用于覆盖公共的配置
    request<T = any>(config: AxiosRequestConfig, curRequestOtions?: RequestOptions): Promise<T> {
        return new Promise((resolve, reject) => {
            this.axiosInstance
                .request<T>(config)
                .then((res) => {
                    resolve(res.data);
                })
                .catch((error) => {
                    reject(error);
                });
        });
    }
    get<T = any>(url: string, data: any): Promise<T>;
    get<T = any>(config: AxiosRequestConfig, curRequestOtions?: RequestOptions): Promise<T>;
    get(config: string | AxiosRequestConfig, data: any = undefined) {
        let conf: AxiosRequestConfig = {};
        let options = undefined;
        if (typeof config === 'string') {
            conf.url = config;
            conf.params = data;
        } else {
            conf = config;
            if (data) {
                options = data;
            }
        }
        return this.request({ ...conf, method: 'GET' }, options);
    }
    post<T = any>(url: string, data: any): Promise<T>;
    post<T = any>(config: AxiosRequestConfig, curRequestOtions?: RequestOptions): Promise<T>;
    post(config: string | AxiosRequestConfig, data: any = undefined) {
        let conf: AxiosRequestConfig = {};
        let options = undefined;
        if (typeof config === 'string') {
            conf.url = config;
            conf.params = data;
        } else {
            conf = config;
            if (data) {
                options = data;
            }
        }
        return this.request({ ...conf, method: 'POST' }, options);
    }
}
export const request = new Request({});

经过上面的简单封装之后,你就可以像如下这样调用了

// 你既可以这样调用
request.post('/api/xxx', { a:1, b: 2 });
// 也可以像下面这样调用
request.post({
    url: '/api/xxx',
    data: {
        a: 1,
        b: 2,
    },
});

增加拦截器支持

axios的拦截器可以让我们在请求前,响应前等这些时机去做一些事情。比如请求前统一开启loading等,就可以通过拦截器做。所以我们需要在Request类里增加一个设置拦截器的方法,去帮我们设置请求/响应/异常拦截器,如下所示

// curHooks 如果有传,就表示是当次请求自定义的拦截器
setInterceptors(curHooks?: RequestHooks, customOptions?: CustomOptions) {
    let hooks: RequestHooks = this.options?.hooks || {};
    if (curHooks) {
        hooks = curHooks;
    }
    if (!hooks) {
        return;
    }

    // 将所有拦截器都规范化为数组
    Object.keys(hooks).forEach((key) => {
        // @ts-ignore
        if (!Array.isArray(hooks[key])) {
            // @ts-ignore
            hooks[key] = [hooks[key]];
        }
    });

    const {
        requestInterceptors,
        responseInterceptors,
        requestErrorInterceptors,
        responseErrorInterceptors,
    } = hooks;

    // 请求拦截器,支持传多个
    if (requestInterceptors) {
        (requestInterceptors as RequestInterceptorType[]).forEach((interceptor) => {
            this.axiosInstance.interceptors.request.use((config) => {
                return interceptor(config, this.getCustomOptions(customOptions || {}));
            });
        });
    }

    if (requestErrorInterceptors) {
        (requestErrorInterceptors as RequestErrorInterceptorType[]).forEach((interceptor) => {
            this.axiosInstance.interceptors.request.use(undefined, (err) => {
                return interceptor(err, this.getCustomOptions(customOptions || {}));
            });
        });
    }

    // 响应拦截器
    if (responseInterceptors) {
        (responseInterceptors as ResponseInterceptorType[]).forEach((interceptor) => {
            this.axiosInstance.interceptors.response.use((resp) => {
                return interceptor(resp, this.getCustomOptions(customOptions || {}));
            });
        });
    }

    if (responseErrorInterceptors) {
        (responseErrorInterceptors as ResponseErrorInterceptorType[]).forEach((interceptor) => {
            this.axiosInstance.interceptors.response.use(undefined, (err) => {
                return interceptor(err, this.getCustomOptions(customOptions || {}));
            });
        });
    }
}

此时,当我们创建Requst实例的时候,就可以像如下代码一样添加拦截器了

export const request = new Request({
    hooks: {
        requestInterceptors: [
            (config) => {
                console.log('请求拦截器111');
                return config;
            },
            (config) => {
                console.log('请求拦截器222');
                return config;
            },
        ],
        responseInterceptors: (resp) => {
            console.log('响应拦截器', resp);
            return resp;
        },
    },
});
// 我们也可以在每次请求的时候传进拦截器,让这个请求可以有自己的特殊处理逻辑
request.post({ url: 'xxx' }, {
    hooks: {
         requestInterceptors: (config) => {
            console.log('针对本次请求的拦截器');
            return config;
        },
    },
});

按功能划分出拦截器

因为我们的Request类是支持传递多个拦截器的,所以我们按功能将拦截器拆分出来,单独存放在一个目录中,目录可以看下面,然后以数组的形式传递给Request,像上面的代码的请求拦截器一样,handleUploadFile就是支持文件上传的拦截器,handleUrlencoded就是支持参数序列化的拦截器,此时我们的请求工具源码目录如下:

api
├── apiList                                按模块存放具体的api接口
│   ├── demo                               demo模块的接口
│   └── user                               用户模块的接口
│       ├── apiConfig.ts
│       └── index.ts
└── request                                请求工具类
    ├── CancelRequest.ts
    ├── Loading.ts
    ├── Request.ts
    ├── RetryRequest.ts
    ├── index.ts
    └── interceptors                       拦截器
        ├── request                        请求拦截器
        │   ├── cancelRequest.ts           处理重复请求
        │   ├── index.ts                   统一的出口
        │   ├── uploadfile.ts              处理文件上传
        │   └── urlencoded.ts              处理参数序列化
        └── response
            └── commonResponseHandler.ts   响应拦截器

此时我们要做功能扩展就很简单了,只需要在拦截器目录下新增一个拦截器,然后在拦截器里做相应的功能扩展即可,这样也不会影响其他的代码,降低风险。

通过拦截器支持请求参数序列化

经过上面划分好拦截器以后,我们就可以做相关的处理。首先是参数序列化,对于Content-Typeapplication/x-www-form-urlencoded的请求,通常是表单提交,我们需要用qs对参数进行序列化,如下:

// 处理表单编码的拦截器
const handleUrlencoded = (config: AxiosRequestConfig): AxiosRequestConfig => {
    const headers = config.headers;
    const contentType = headers?.['Content-Type'] || headers?.['content-type'];
    // 如果content-type 不是 urlencode或没有data或者是get请求,则无需编码
    if (
    contentType !== ContentType.FORM_URLENCODED ||
    !config.data ||
    config.method?.toUpperCase() === RequestMethod.GET
    ) {
        return config;
    }
    return { ...config, data: qs.stringify(config.data) };
};

支持文件上传

因为项目中可能需要进行图片上传等,所以我们也需要支持文件上传,这个也可以在请求拦截器中统一处理,具体逻辑如下:

// 处理文件上传的拦截器
const handleUploadFile = (config: AxiosRequestConfig): AxiosRequestConfig => {
    const headers = config.headers;
    const contentType = headers?.['Content-Type'] || headers?.['content-type'];
    // 如果content-type 不是 form-data或没有data或者是get请求,则无需编码
    if (
    contentType !== ContentType.FORM_DATA ||
    !config.data ||
    config.method?.toUpperCase() === RequestMethod.GET
    ) {
        return config;
    }
    const formData = new FormData();
    const uploadFilename = config.data.filename || 'file';
    if (config.data.filename) {
        formData.append(uploadFilename, config.data.file, config.data.filename);
    } else {
        formData.append(uploadFilename, config.data.file);
    }
    if (config.params) {
        Object.keys(config.params).forEach((key) => {
        formData.append(key, config.params[key]);
        });
        config.params = undefined;
    }
    return {
        ...config,
        data: formData,
    };
};

支持取消重复请求

假如说上一个请求还没响应的情况下,又发起了一次同样的请求,此时就需要将重复的请求取消了。你可以取消本次的,也可以取消上一次的,用最新的,具体的策略就按自己的项目来定了,我们本次采取的策略是取消上一次的,用最新的请求结果。

请求取消器的具体实现如下,本质上是通过cancelToken来实现的

const cancelMap = new Map<string, Canceler>();
// 取消重复请求
class CancelRequest {
    static addPending(config: AxiosRequestConfig) {
        // 如果有上一次的重复请求,先取消上一次的
        this.removePending(config);
        // url相同,method相同,请求参数相同则认为是重复的请求
        const url = getFullUrl(config);
        config.cancelToken =
        config.cancelToken ||
        new axios.CancelToken((cancel) => {
            if (!cancelMap.has(url)) {
                // 保存取消的方法,到时候取消的时候就拿出来调用
                cancelMap.set(url, cancel);
            }
        });
    }
    static removePending(config: AxiosRequestConfig) 
        const url = getFullUrl(config);
        if (cancelMap.has(url)) {
            const cancel = cancelMap.get(url);
            // 取出上面保存的cancel方法,然后调用,就可以取消了
            cancel && cancel();
            cancelMap.delete(url);
        }
    }
}

需要注意的是,取消请求只是不接收结果,实际上请求已经到了服务器了,你也可以采取取消本次请求的策略,发现有上一次的重复请求,则不发起本次的请求了,这样请求就不会到达服务器了。、

响应处理

在响应拦截器里,我们可以对响应结果做相应的处理,比如结果转化,业务错误处理,关闭Loading等, 如下代码所示:

// 通用响应处理
export const commonResponseHandler = (
    resp: AxiosResponse<any>,
    customOptions: CustomOptions
): AxiosResponse => {
    CancelRequest.removePending(resp.config);
    const { needOriginResponse } = customOptions;
    // 如果业务层需要原始的响应,则直接返回
    if (needOriginResponse) {
        return resp;
    }
    // 没有报文体
    if (!resp.data) {
        throw new Error('请求错误,请重试');
    }
    const { code, data, errMsg } = resp.data;
    // 业务成功状态码
    if (code === ResponseResultCode.SUCCESS) {
        return data;
    }
    // 业务失败处理
    // 你可以根据自己业务的错误码做相应的处理,也可以将错误抛到业务层,让具体的业务自己处理,具体看自己的需要
    throw new Error(errMsg || '系统繁忙');
};

出错重试

假如我们的接口出现了非业务的异常,像客户端超时等,则可以进行自动重试。我们可以在异常拦截器里进行重试处理,当然,我不建议所有的接口都进行自动错误重试,因为可能会有意料之外的错误,建议具体按接口维度来配置是否需要重试

重试的代码如下

export class RequestRetry {
    static retry(axiosInstance: AxiosInstance, error: AxiosError, customOptions: CustomOptions) {
        const config: any = error.config;
        const retryRequest = customOptions.retryRequest;
        if (retryRequest) {
        const { retryTimes, waitTime } = retryRequest;
        config.retryTimes = config.retryTimes || 0;
        // 超过次数则抛出错误
        if (retryTimes >= retryTimes) {
            return Promise.reject(error);
        }
        config.retryTimes++;
        return new Promise((resolve) => {
                    setTimeout(resolve, waitTime);
                }).then(() => {
                    //本质上还是通过axios实例,然后重新调用
                    return axiosInstance(config);
                });
        }
    }
}

到此,其实基于axios的请求工具已经封装好了,具体如果有需要扩展功能,只要按照上面的例子自己增加拦截器即可。

接口管理

对于一个大型的项目,我们希望可以将接口统一收拢管理,形成统一的规范,而不是哪里使用,就在哪里直接request('xxx.json', {xxx})这样的方式调用,这样不以利维护。刚刚上面的目录里面有一个apiList的目录,如下,我们就在这里统一管理我们的接口

api
├── apiList                                按模块存放具体的api接口
│   ├── demo                               demo模块的接口
│   └── user                               用户模块的接口
│       ├── apiConfig.ts                   接口的配置,可以每个接口配置是否需要loading等
│       └── index.ts

以上面的user模块为例,apiConfig的内容如下

// 接口配置
export default {
    // 每个接口的具体配置
    getUserInfo: {
        url: '/users/getUserInfo',
        name: '获取用户信息',
        showErrorMsg: true,
        showLoading: true,
    },
};

可以看出,我们可以针对每个具体的接口做差异化的配置,比如这个接口是否需要展示loading等

index.ts的内容如下,其实就是用apiConfg的配置去做请求,具体如下

export const getUserInfo = (params: { uid: string }) => {
    return request.post<UserInfo>(apiConfig.getUserInfo, params);
};

后续我们新增接口,则统一在这个目录下像上面一样的处理就可以了。

总结

其实对请求的封装,最核心的是利用axios的拦截器做的不同功能的扩展。假如你的项目只是很小的,可以无需像上面一样搞这么多,对于稍大规模的项目,还是需要做统一的收拢处理的。