mirror of
https://github.com/Tencent/tdesign-vue-next-starter.git
synced 2024-12-23 04:36:31 +08:00
refactor: 重构Axios工具,新增接口级防抖节流 (#556)
* refactor: 重构Axios工具,新增接口级防抖节流 * fix: 修复编译类型问题 * perf: 优化注释
This commit is contained in:
parent
17eb3f0aa8
commit
12909e5d22
75
src/types/axios.d.ts
vendored
75
src/types/axios.d.ts
vendored
|
@ -1,18 +1,89 @@
|
||||||
import { AxiosRequestConfig } from 'axios';
|
import type { AxiosRequestConfig } from 'axios';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Axios请求配置
|
||||||
|
*/
|
||||||
export interface RequestOptions {
|
export interface RequestOptions {
|
||||||
|
/**
|
||||||
|
* 接口地址
|
||||||
|
*
|
||||||
|
* 例: http://www.baidu.com/api
|
||||||
|
*/
|
||||||
apiUrl?: string;
|
apiUrl?: string;
|
||||||
|
/**
|
||||||
|
* 是否自动添加接口前缀
|
||||||
|
*
|
||||||
|
* 例: http://www.baidu.com/api
|
||||||
|
* urlPrefix: 'api'
|
||||||
|
*/
|
||||||
isJoinPrefix?: boolean;
|
isJoinPrefix?: boolean;
|
||||||
|
/**
|
||||||
|
* 接口前缀
|
||||||
|
*/
|
||||||
urlPrefix?: string;
|
urlPrefix?: string;
|
||||||
|
/**
|
||||||
|
* POST请求的时候添加参数到Url中
|
||||||
|
*/
|
||||||
joinParamsToUrl?: boolean;
|
joinParamsToUrl?: boolean;
|
||||||
|
/**
|
||||||
|
* 格式化提交参数时间
|
||||||
|
*/
|
||||||
formatDate?: boolean;
|
formatDate?: boolean;
|
||||||
|
/**
|
||||||
|
* 是否需要对响应数据进行处理
|
||||||
|
*/
|
||||||
isTransformResponse?: boolean;
|
isTransformResponse?: boolean;
|
||||||
|
/**
|
||||||
|
* 是否返回原生响应头
|
||||||
|
*
|
||||||
|
* 例: 需要获取响应头时使用该属性
|
||||||
|
*/
|
||||||
isReturnNativeResponse?: boolean;
|
isReturnNativeResponse?: boolean;
|
||||||
ignoreRepeatRequest?: boolean;
|
/**
|
||||||
|
* 是否忽略请求取消令牌
|
||||||
|
*
|
||||||
|
* 如果启用,则重复请求时不进行处理
|
||||||
|
*
|
||||||
|
* 如果禁用,则重复请求时会取消当前请求
|
||||||
|
*/
|
||||||
|
ignoreCancelToken?: boolean;
|
||||||
|
/**
|
||||||
|
* 自动对请求添加时间戳参数
|
||||||
|
*/
|
||||||
joinTime?: boolean;
|
joinTime?: boolean;
|
||||||
|
/**
|
||||||
|
* 是否携带Token
|
||||||
|
*/
|
||||||
withToken?: boolean;
|
withToken?: boolean;
|
||||||
|
/**
|
||||||
|
* 重试配置
|
||||||
|
*/
|
||||||
retry?: {
|
retry?: {
|
||||||
|
/**
|
||||||
|
* 重试次数
|
||||||
|
*/
|
||||||
count: number;
|
count: number;
|
||||||
|
/**
|
||||||
|
* 隔多久重试
|
||||||
|
*
|
||||||
|
* 单位: 毫秒
|
||||||
|
*/
|
||||||
|
delay: number;
|
||||||
|
};
|
||||||
|
/**
|
||||||
|
* 接口级节流
|
||||||
|
*
|
||||||
|
* 单位: 毫秒
|
||||||
|
*/
|
||||||
|
throttle?: {
|
||||||
|
delay: number;
|
||||||
|
};
|
||||||
|
/**
|
||||||
|
* 接口级防抖
|
||||||
|
*
|
||||||
|
* 单位: 毫秒
|
||||||
|
*/
|
||||||
|
debounce?: {
|
||||||
delay: number;
|
delay: number;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,6 +1,15 @@
|
||||||
import axios, { AxiosError, AxiosInstance, AxiosRequestConfig, AxiosResponse, InternalAxiosRequestConfig } from 'axios';
|
import axios, {
|
||||||
|
AxiosError,
|
||||||
|
AxiosInstance,
|
||||||
|
AxiosRequestConfig,
|
||||||
|
AxiosRequestHeaders,
|
||||||
|
AxiosResponse,
|
||||||
|
InternalAxiosRequestConfig,
|
||||||
|
} from 'axios';
|
||||||
import cloneDeep from 'lodash/cloneDeep';
|
import cloneDeep from 'lodash/cloneDeep';
|
||||||
|
import debounce from 'lodash/debounce';
|
||||||
import isFunction from 'lodash/isFunction';
|
import isFunction from 'lodash/isFunction';
|
||||||
|
import throttle from 'lodash/throttle';
|
||||||
import { stringify } from 'qs';
|
import { stringify } from 'qs';
|
||||||
|
|
||||||
import { ContentTypeEnum } from '@/constants';
|
import { ContentTypeEnum } from '@/constants';
|
||||||
|
@ -9,12 +18,20 @@ import { AxiosRequestConfigRetry, RequestOptions, Result } from '@/types/axios';
|
||||||
import { AxiosCanceler } from './AxiosCancel';
|
import { AxiosCanceler } from './AxiosCancel';
|
||||||
import { CreateAxiosOptions } from './AxiosTransform';
|
import { CreateAxiosOptions } from './AxiosTransform';
|
||||||
|
|
||||||
// Axios模块
|
/**
|
||||||
|
* Axios 模块
|
||||||
|
*/
|
||||||
export class VAxios {
|
export class VAxios {
|
||||||
// axios句柄
|
/**
|
||||||
|
* Axios实例句柄
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
private instance: AxiosInstance;
|
private instance: AxiosInstance;
|
||||||
|
|
||||||
// axios选项
|
/**
|
||||||
|
* Axios配置
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
private readonly options: CreateAxiosOptions;
|
private readonly options: CreateAxiosOptions;
|
||||||
|
|
||||||
constructor(options: CreateAxiosOptions) {
|
constructor(options: CreateAxiosOptions) {
|
||||||
|
@ -23,57 +40,71 @@ export class VAxios {
|
||||||
this.setupInterceptors();
|
this.setupInterceptors();
|
||||||
}
|
}
|
||||||
|
|
||||||
// 创建axios句柄
|
/**
|
||||||
|
* 创建Axios实例
|
||||||
|
* @param config
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
private createAxios(config: CreateAxiosOptions): void {
|
private createAxios(config: CreateAxiosOptions): void {
|
||||||
this.instance = axios.create(config);
|
this.instance = axios.create(config);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 获取数据处理
|
/**
|
||||||
|
* 获取数据处理类
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
private getTransform() {
|
private getTransform() {
|
||||||
const { transform } = this.options;
|
const { transform } = this.options;
|
||||||
return transform;
|
return transform;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 获取句柄
|
/**
|
||||||
|
* 获取Axios实例
|
||||||
|
*/
|
||||||
getAxios(): AxiosInstance {
|
getAxios(): AxiosInstance {
|
||||||
return this.instance;
|
return this.instance;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 配置 axios
|
/**
|
||||||
|
* 配置Axios
|
||||||
|
* @param config
|
||||||
|
*/
|
||||||
configAxios(config: CreateAxiosOptions) {
|
configAxios(config: CreateAxiosOptions) {
|
||||||
if (!this.instance) {
|
if (!this.instance) return;
|
||||||
return;
|
|
||||||
}
|
|
||||||
this.createAxios(config);
|
this.createAxios(config);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 设置通用头信息
|
/**
|
||||||
|
* 设置公共头部信息
|
||||||
|
* @param headers
|
||||||
|
*/
|
||||||
setHeader(headers: Record<string, string>): void {
|
setHeader(headers: Record<string, string>): void {
|
||||||
if (!this.instance) {
|
if (!this.instance) return;
|
||||||
return;
|
|
||||||
}
|
|
||||||
Object.assign(this.instance.defaults.headers, headers);
|
Object.assign(this.instance.defaults.headers, headers);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 设置拦截器
|
/**
|
||||||
|
* 设置拦截器
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
private setupInterceptors() {
|
private setupInterceptors() {
|
||||||
const transform = this.getTransform();
|
const transform = this.getTransform();
|
||||||
if (!transform) {
|
if (!transform) return;
|
||||||
return;
|
|
||||||
}
|
|
||||||
const { requestInterceptors, requestInterceptorsCatch, responseInterceptors, responseInterceptorsCatch } =
|
const { requestInterceptors, requestInterceptorsCatch, responseInterceptors, responseInterceptorsCatch } =
|
||||||
transform;
|
transform;
|
||||||
const axiosCanceler = new AxiosCanceler();
|
const axiosCanceler = new AxiosCanceler();
|
||||||
|
|
||||||
// 请求配置处理
|
// 请求拦截器
|
||||||
this.instance.interceptors.request.use((config: InternalAxiosRequestConfig) => {
|
this.instance.interceptors.request.use((config: InternalAxiosRequestConfig) => {
|
||||||
|
// 如果忽略取消令牌,则不会取消重复的请求
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
const { ignoreRepeatRequest } = config.requestOptions;
|
const { ignoreCancelToken } = config.requestOptions;
|
||||||
const ignoreRepeat = ignoreRepeatRequest ?? this.options.requestOptions?.ignoreRepeatRequest;
|
const ignoreCancel = ignoreCancelToken ?? this.options.requestOptions?.ignoreCancelToken;
|
||||||
if (!ignoreRepeat) axiosCanceler.addPending(config);
|
if (!ignoreCancel) axiosCanceler.addPending(config);
|
||||||
|
|
||||||
if (requestInterceptors && isFunction(requestInterceptors)) {
|
if (requestInterceptors && isFunction(requestInterceptors)) {
|
||||||
config = requestInterceptors(config, this.options);
|
config = requestInterceptors(config, this.options) as InternalAxiosRequestConfig;
|
||||||
}
|
}
|
||||||
|
|
||||||
return config;
|
return config;
|
||||||
|
@ -99,9 +130,12 @@ export class VAxios {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 支持Form Data
|
/**
|
||||||
|
* 支持 FormData 请求格式
|
||||||
|
* @param config
|
||||||
|
*/
|
||||||
supportFormData(config: AxiosRequestConfig) {
|
supportFormData(config: AxiosRequestConfig) {
|
||||||
const headers = config.headers || this.options.headers;
|
const headers = config.headers || (this.options.headers as AxiosRequestHeaders);
|
||||||
const contentType = headers?.['Content-Type'] || headers?.['content-type'];
|
const contentType = headers?.['Content-Type'] || headers?.['content-type'];
|
||||||
|
|
||||||
if (
|
if (
|
||||||
|
@ -118,7 +152,10 @@ export class VAxios {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// 支持params数组参数格式化
|
/**
|
||||||
|
* 支持 params 序列化
|
||||||
|
* @param config
|
||||||
|
*/
|
||||||
supportParamsStringify(config: AxiosRequestConfig) {
|
supportParamsStringify(config: AxiosRequestConfig) {
|
||||||
const headers = config.headers || this.options.headers;
|
const headers = config.headers || this.options.headers;
|
||||||
const contentType = headers?.['Content-Type'] || headers?.['content-type'];
|
const contentType = headers?.['Content-Type'] || headers?.['content-type'];
|
||||||
|
@ -153,8 +190,62 @@ export class VAxios {
|
||||||
return this.request({ ...config, method: 'PATCH' }, options);
|
return this.request({ ...config, method: 'PATCH' }, options);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 请求
|
/**
|
||||||
async request<T = any>(config: AxiosRequestConfigRetry, options?: RequestOptions): Promise<T> {
|
* 上传文件封装
|
||||||
|
* @param key 文件所属的key
|
||||||
|
* @param file 文件
|
||||||
|
* @param config 请求配置
|
||||||
|
* @param options
|
||||||
|
*/
|
||||||
|
upload<T = any>(key: string, file: File, config: AxiosRequestConfig, options?: RequestOptions): Promise<T> {
|
||||||
|
const params: FormData = config.params ?? new FormData();
|
||||||
|
params.append(key, file);
|
||||||
|
|
||||||
|
return this.request(
|
||||||
|
{
|
||||||
|
...config,
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': ContentTypeEnum.FormData,
|
||||||
|
},
|
||||||
|
params,
|
||||||
|
},
|
||||||
|
options,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 请求封装
|
||||||
|
* @param config
|
||||||
|
* @param options
|
||||||
|
*/
|
||||||
|
request<T = any>(config: AxiosRequestConfigRetry, options?: RequestOptions): Promise<T> {
|
||||||
|
const { requestOptions } = this.options;
|
||||||
|
|
||||||
|
if (requestOptions.throttle !== undefined && requestOptions.debounce !== undefined) {
|
||||||
|
throw new Error('throttle and debounce cannot be set at the same time');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (requestOptions.throttle && requestOptions.throttle.delay !== 0) {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
throttle(() => resolve(this.synthesisRequest(config, options)), requestOptions.throttle.delay);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (requestOptions.debounce && requestOptions.debounce.delay !== 0) {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
debounce(() => resolve(this.synthesisRequest(config, options)), requestOptions.debounce.delay);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.synthesisRequest(config, options);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 请求方法
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
private async synthesisRequest<T = any>(config: AxiosRequestConfigRetry, options?: RequestOptions): Promise<T> {
|
||||||
let conf: CreateAxiosOptions = cloneDeep(config);
|
let conf: CreateAxiosOptions = cloneDeep(config);
|
||||||
const transform = this.getTransform();
|
const transform = this.getTransform();
|
||||||
|
|
||||||
|
|
|
@ -5,10 +5,20 @@ import isFunction from 'lodash/isFunction';
|
||||||
// 存储请求与取消令牌的键值对列表
|
// 存储请求与取消令牌的键值对列表
|
||||||
let pendingMap = new Map<string, Canceler>();
|
let pendingMap = new Map<string, Canceler>();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取请求Url
|
||||||
|
* @param config
|
||||||
|
*/
|
||||||
export const getPendingUrl = (config: AxiosRequestConfig) => [config.method, config.url].join('&');
|
export const getPendingUrl = (config: AxiosRequestConfig) => [config.method, config.url].join('&');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @description 请求管理器
|
||||||
|
*/
|
||||||
export class AxiosCanceler {
|
export class AxiosCanceler {
|
||||||
// 添加请求到列表
|
/**
|
||||||
|
* 添加请求到列表中
|
||||||
|
* @param config
|
||||||
|
*/
|
||||||
addPending(config: AxiosRequestConfig) {
|
addPending(config: AxiosRequestConfig) {
|
||||||
this.removePending(config);
|
this.removePending(config);
|
||||||
const url = getPendingUrl(config);
|
const url = getPendingUrl(config);
|
||||||
|
@ -22,7 +32,9 @@ export class AxiosCanceler {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// 清空所有请求
|
/**
|
||||||
|
* 移除现有的所有请求
|
||||||
|
*/
|
||||||
removeAllPending() {
|
removeAllPending() {
|
||||||
pendingMap.forEach((cancel) => {
|
pendingMap.forEach((cancel) => {
|
||||||
if (cancel && isFunction(cancel)) cancel();
|
if (cancel && isFunction(cancel)) cancel();
|
||||||
|
@ -30,7 +42,10 @@ export class AxiosCanceler {
|
||||||
pendingMap.clear();
|
pendingMap.clear();
|
||||||
}
|
}
|
||||||
|
|
||||||
// 移除某个请求
|
/**
|
||||||
|
* 移除指定请求
|
||||||
|
* @param config
|
||||||
|
*/
|
||||||
removePending(config: AxiosRequestConfig) {
|
removePending(config: AxiosRequestConfig) {
|
||||||
const url = getPendingUrl(config);
|
const url = getPendingUrl(config);
|
||||||
|
|
||||||
|
@ -43,6 +58,9 @@ export class AxiosCanceler {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 重置
|
||||||
|
*/
|
||||||
reset() {
|
reset() {
|
||||||
pendingMap = new Map<string, Canceler>();
|
pendingMap = new Map<string, Canceler>();
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,38 +1,64 @@
|
||||||
import type { AxiosInstance, AxiosRequestConfig, AxiosResponse, InternalAxiosRequestConfig } from 'axios';
|
import type { AxiosInstance, AxiosRequestConfig, AxiosResponse } from 'axios';
|
||||||
import { AxiosError } from 'axios';
|
import { AxiosError } from 'axios';
|
||||||
|
|
||||||
import type { RequestOptions, Result } from '@/types/axios';
|
import type { RequestOptions, Result } from '@/types/axios';
|
||||||
|
|
||||||
// 创建Axios选项
|
/**
|
||||||
|
* @description 创建Axios实例配置
|
||||||
|
*/
|
||||||
export interface CreateAxiosOptions extends AxiosRequestConfig {
|
export interface CreateAxiosOptions extends AxiosRequestConfig {
|
||||||
// https://developer.mozilla.org/en-US/docs/Web/HTTP/Authentication#authentication_schemes
|
/**
|
||||||
|
* 请求验证方案
|
||||||
|
*
|
||||||
|
* https://developer.mozilla.org/en-US/docs/Web/HTTP/Authentication#authentication_schemes
|
||||||
|
*/
|
||||||
authenticationScheme?: string;
|
authenticationScheme?: string;
|
||||||
// 数据处理
|
/**
|
||||||
|
* 请求数据处理
|
||||||
|
*/
|
||||||
transform?: AxiosTransform;
|
transform?: AxiosTransform;
|
||||||
// 请求选项
|
/**
|
||||||
|
* 请求配置
|
||||||
|
*/
|
||||||
requestOptions?: RequestOptions;
|
requestOptions?: RequestOptions;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Axios 数据处理
|
/**
|
||||||
|
* Axios请求数据处理 抽象类
|
||||||
|
*/
|
||||||
export abstract class AxiosTransform {
|
export abstract class AxiosTransform {
|
||||||
// 请求前Hook
|
/**
|
||||||
|
* 请求前钩子
|
||||||
|
*/
|
||||||
beforeRequestHook?: (config: AxiosRequestConfig, options: RequestOptions) => AxiosRequestConfig;
|
beforeRequestHook?: (config: AxiosRequestConfig, options: RequestOptions) => AxiosRequestConfig;
|
||||||
|
|
||||||
// 转换前Hook
|
/**
|
||||||
transformRequestHook?: (res: AxiosResponse<Result>, options: RequestOptions) => any;
|
* 数据处理前钩子
|
||||||
|
*/
|
||||||
|
transformRequestHook?: <T = any>(res: AxiosResponse<Result>, options: RequestOptions) => T;
|
||||||
|
|
||||||
// 请求失败处理
|
/**
|
||||||
requestCatchHook?: (e: Error | AxiosError, options: RequestOptions) => Promise<any>;
|
* 请求失败钩子
|
||||||
|
*/
|
||||||
|
requestCatchHook?: <T = any>(e: Error | AxiosError, options: RequestOptions) => Promise<T>;
|
||||||
|
|
||||||
// 请求前的拦截器
|
/**
|
||||||
requestInterceptors?: (config: AxiosRequestConfig, options: CreateAxiosOptions) => InternalAxiosRequestConfig;
|
* 请求拦截器
|
||||||
|
*/
|
||||||
|
requestInterceptors?: (config: AxiosRequestConfig, options: CreateAxiosOptions) => AxiosRequestConfig;
|
||||||
|
|
||||||
// 请求后的拦截器
|
/**
|
||||||
|
* 响应拦截器
|
||||||
|
*/
|
||||||
responseInterceptors?: (res: AxiosResponse) => AxiosResponse;
|
responseInterceptors?: (res: AxiosResponse) => AxiosResponse;
|
||||||
|
|
||||||
// 请求前的拦截器错误处理
|
/**
|
||||||
|
* 请求拦截器错误处理
|
||||||
|
*/
|
||||||
requestInterceptorsCatch?: (error: AxiosError) => void;
|
requestInterceptorsCatch?: (error: AxiosError) => void;
|
||||||
|
|
||||||
// 请求后的拦截器错误处理
|
/**
|
||||||
|
* 响应拦截器错误处理
|
||||||
|
*/
|
||||||
responseInterceptorsCatch?: (error: AxiosError, instance: AxiosInstance) => void;
|
responseInterceptorsCatch?: (error: AxiosError, instance: AxiosInstance) => void;
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
// axios配置 可自行根据项目进行更改,只需更改该文件即可,其他文件可以不动
|
// axios配置 可自行根据项目进行更改,只需更改该文件即可,其他文件可以不动
|
||||||
import type { AxiosInstance, InternalAxiosRequestConfig } from 'axios';
|
import type { AxiosInstance } from 'axios';
|
||||||
import isString from 'lodash/isString';
|
import isString from 'lodash/isString';
|
||||||
import merge from 'lodash/merge';
|
import merge from 'lodash/merge';
|
||||||
|
|
||||||
|
@ -122,7 +122,7 @@ const transform: AxiosTransform = {
|
||||||
? `${options.authenticationScheme} ${token}`
|
? `${options.authenticationScheme} ${token}`
|
||||||
: token;
|
: token;
|
||||||
}
|
}
|
||||||
return config as InternalAxiosRequestConfig;
|
return config;
|
||||||
},
|
},
|
||||||
|
|
||||||
// 响应拦截器处理
|
// 响应拦截器处理
|
||||||
|
@ -186,8 +186,10 @@ function createAxios(opt?: Partial<CreateAxiosOptions>) {
|
||||||
formatDate: true,
|
formatDate: true,
|
||||||
// 是否加入时间戳
|
// 是否加入时间戳
|
||||||
joinTime: true,
|
joinTime: true,
|
||||||
// 忽略重复请求
|
// 是否忽略请求取消令牌
|
||||||
ignoreRepeatRequest: true,
|
// 如果启用,则重复请求时不进行处理
|
||||||
|
// 如果禁用,则重复请求时会取消当前请求
|
||||||
|
ignoreCancelToken: true,
|
||||||
// 是否携带token
|
// 是否携带token
|
||||||
withToken: true,
|
withToken: true,
|
||||||
// 重试
|
// 重试
|
||||||
|
|
Loading…
Reference in New Issue
Block a user