跳到主要内容

oss阿里云

文件上传到oss阿里云​

一般文件上传使用oss阿里云,oss支持很多文件上传功能,如支持分片上传、支持断点、支持上传进度、支持批量上传

  • 1、点击文件上传
  • 2、获取文件类型名称以及MD5
  • 3、调用后端校验接口,返回上传等状态
  • 4、通过对可以上传的文件,进行上传到阿里云
  • 5、上传阿里云成功之后,会返回URL
  • 6、把URL传给后端接口

参考

https://juejin.cn/post/7077751294223450143?searchId=20240629111611A71EB0FC7EAB2FDE6275#heading-19

  • 1、拿到阿里云的oss相关参数
    • accessKeyId :访问密钥ID,是用于身份验证的标识符
    • accessKeySecret:访问密钥密钥, 是用于身份验证的密钥
    • securityToken:安全令牌
    • bucket:存储桶名称,是OSS中用于存储对象的容器
    • region:地区,指的是OSS服务的位置
    • expiration:过期时间,指的是临时访问权限的过期时间

一般这些参数,通过后端接口来获取,前端不要写死

  • 2、使用oss的npm sdk
    • ali-oss
    • oss的sdk已经封装好了,支持文件切片、上传进度

基本使用​

  • 调用后端接口,拿到oss上传的相关参数 (accessKeyId、accessKeySecret等等)
  • 计算文件md5,使用spark-md5

初始化oss​

js
const client = new OSS({
accessKeyId: '',
accessKeySecret: '',
stsToken:'',
bucket: '',
region: '',
secure: true,
refreshSTSToken: async () => {
// 刷新临时访问凭证的回调,在上传大文件的时候会用到
const info = await Api.token; // 刷新token
return {
accessKeyId: info.accessKeyId,
accessKeySecret: info.accessKeySecret,
stsToken: info.securityToken,
};
},
refreshSTSTokenInterval: 3000,
});

计算文件MD5​

js
function calculateMD5(file, chunkSize = 2 * 1024 * 1024) {
return new Promise((resolve, reject) => {
// 创建一个SparkMD5.ArrayBuffer实例用于计算MD5。
const spark = new SparkMD5.ArrayBuffer();
// 创建一个FileReader实例用于读取文件
const fileReader = new FileReader();
// 设置offset变量来跟踪已读取的字节
let offset = 0;

// 当文件读取成功时触发
fileReader.onload = (e: any) => {
// 将读取到的数据(e.target.result)追加到MD5计算器中
spark.append(e.target.result);
offset += chunkSize;
if (offset < file.size) {
loadNext();
} else {
const md5 = spark.end();
resolve(md5);
}
};

// 当文件读取出错时触发
fileReader.onerror = (e) => {
reject(e);
};

// 用于读取文件的下一块
function loadNext() {
const blob = file.slice(offset, offset + chunkSize);
// 读取块数据
fileReader.readAsArrayBuffer(blob);
}
loadNext();
});
}

分片上传​

当需要上传的文件较大时,您可以通过MultipartUpload方法进行分片上传。分片上传是指将要上传的文件分成多个数据块

当其中一些分片上传失败后,OSS将保留上传进度记录。当您再次重传时,只需要上传失败的分片,不需要重新上传整个文件

  • 使用MultipartUpload方法

上传进度​

  • 通过progress配置项,可以拿到上传进度
js
client.multipartUpload(文件路径, 文件对象,
{
// 进度条的配置项
progress: function(percentage) {
// 获取进度条的值
const percent = percentage * 100
console.log(percent)
},
// 每传输完一个分片 进度条会走一个百分比 不设置的话由于partsize过大 可能导致很长时间进度条都看不到效果
partSize: 1024 * 1024 // 指定上传的每个分片的大小,范围为100 KB~5 GB。单个分片默认大小为1 * 1024 * 1024(即1 MB)
timeout: 120000, //设置超时时间
parallel: 4, // 设置并行数量
}
)

oss优化​

  • 把静态资源上传到oss访问,加快访问速度
  • 配置oss的cdn域名加速

自定义域名配置​

使用自定义域名加速之后,上传明显会快很多,不过会增加加速收费标准

对比之后,会有3倍速度的明显的提效

  • 没加速:5s-6s
  • 加速后:1s-2s

文档:绑定自定义域名至Bucket默认域名 - 对象存储 OSS - 阿里云

  • 阿里云 OSS 控制台做了如下配置
  • 配置了oss cdn加速域名,需要设置自定义域名

绑定了 CNAME​

OSS 控制台 → 你的 Bucket → 域名管理 → 绑定域名

  • 已绑定:static.example.com
  • 并已设置解析到 OSS 的默认域名上
  • 并且备案已通过(若是大陆域名)

开启 CNAME 配置上传权限​

阿里云默认不允许通过自定义域名(CNAME)上传文件,你需要开启该选项

  1. 打开你的 OSS Bucket 控制台
  2. 点击左侧【基础设置】
  3. 找到 允许通过 CNAME 进行上传和下载
  4. 勾选 ✅ 允许
  5. 保存设置

oss sdk设置​

  • 设置endpoint、cname
    • 如果没有设置 cname: true,但写了自定义 endpoint,OSS SDK 会自动拼接 .aliyuncs.com,导致域名不匹配,从而返回 403
js
const client = new OSS({
region: 'oss-cn-hangzhou',
accessKeyId: 'xxx',
accessKeySecret: 'xxx',
bucket: 'your-bucket-name',
endpoint: 'https://static.example.com', // 自定义域名
cname: true // 关键配置
})

useOssUpload封装​

useOssUpload​

ts
import { reactive, ref, toValue, watch, type Ref } from 'vue';
import SparkMD5 from 'spark-md5';
import {
cloneDeep,
filter,
find,
findIndex,
get,
head,
last,
map,
remove,
set,
size,
uniqBy,
} from 'lodash-es';
import OSS from 'ali-oss';
import { message } from 'ant-design-vue';
import useUploadSpeed from './useUploadSpeed';
import { useEnvConfig } from './useEnvConfig';
import type { FileType } from 'ant-design-vue/es/upload/interface';
import Api from '@/api';

export enum UploadStatusEnum {
WAITING = 'waiting',
UPLOADING = 'uploading',
SUCCESS = 'success',
ERROR = 'error',
}

export type FileInfo = {
name: string;
size: number | string;
type: string;
md5: unknown;
file: FileType;
id: string;
statusType?: UploadStatusEnum;
};

export type FileData = FileInfo & {
client?: OSS;
url?: string;
};

export type OssUploadSuccessType = {
item: FileData;
res: any;
url: string;
fileIndex: number;
};

export type OssUploadConfigProps = {
tokenApi?: (...args: any) => Promise<any>; // 请求OSS token方法
checkApi?: (...args: any) => Promise<any>; // 检查文件方法
config?: {
limitSize?: number; // 限制文件大小 (默认200M) limitMessage: (name: string) => string; // 限制文件大小提示信息
refreshSTSTokenInterval?: number; // 刷新token间隔 (oss文件上传参数)
batchSize?: number; // 每次上传的文件数量
timeout?: number;
partSize?: number;
limitMessage?: string; // `文件 xxx 大小超过 xxx`,
isCheck?: boolean; // 判断是否检查文件 (文件名、文件状态等)
isDirectUploadOss?: boolean; // 直接上传oss
isMultipartUpload?: boolean; // 是否分片上传
intervalCheckToken?: number; // 定时检查ossToken是否过期
uniqFlag?: 'name' | 'md5'; // 文件唯一标识
multiple?: boolean; // 是否多选
matchNameRuleFn?: (name: string) => string; // 文件名匹配规则
isCalculateMd5?: boolean; // 是否计算md5
};
onSuccess?: ({ item, res, url, fileIndex }: OssUploadSuccessType) => void; // oss上传成功回调
onError?: ({ item, error, fileIndex, isCancel }) => void; // oss上传失败回调
onComplete?: () => void; // 上传完成回调
onBeforeUpload?: (file: FileType) => boolean | Promise<boolean>; // 上传之前回调
onProgress?: ({ percentage, item }) => void; // 上传进度回调
onCustomRequest?: ({ fileList }) => void; // 自定义上传请求回调
fileData?: Ref<ArrayType>; // 文件信息列表数据
credentialsConfig?: ObjectType | Ref<ArrayType>; // 凭据相关信息
};

// 统一OSS上传
export default function useOssUpload(ossUploadConfig?: OssUploadConfigProps) {
const {
tokenApi = Api.material.materialtoken,
checkApi,
config,
onSuccess,
onError,
onComplete,
onBeforeUpload,
onProgress,
onCustomRequest,
fileData,
credentialsConfig,
} = ossUploadConfig || {};
const {
limitSize = 200 * 1024 * 1024,
refreshSTSTokenInterval = 3000,
batchSize = 5,
timeout = 120000,
partSize = 1024 * 1024,
limitMessage = '文件大小,不能超过200M',
isCheck = false,
isDirectUploadOss = true,
isMultipartUpload = true,
intervalCheckToken = 60000, // 每分钟检查一次
uniqFlag = 'name', // 唯一标识,默认是name、md5 (为name文件名时,通过名称时覆盖)
multiple = true,
isCalculateMd5 = false,
} = config || {};

const disabledUpload = ref(false); // 上传禁用状态
const uploading = ref(false); // 上传状态
const fileList = ref<ArrayType>([]); // 文件对象 (上传成功的文件对象列表 => 用于展示相关)
const fileInfoList = ref<FileData[]>([]); // 文件信息列表数据 (用于上传的文件列表,成功上传之后清空当前所项 => 用于上传相关)

// oss上传凭据相关信息
const credentials = reactive<ObjectType>({
accessKeyId: '',
accessKeySecret: '',
securityToken: '',
expiration: '',
bucket: '',
region: '',
path: '',
});

let timerId: NodeJS.Timeout | null = null;
let cancel = false; // 取消上传
const cancelMap = reactive({});

const { filterSize, handle_network_speed } = useUploadSpeed();

const { envConfig } = useEnvConfig();

watch(
() => fileData?.value,
(newFileData) => {
if (size(newFileData)) {
fileInfoList.value = newFileData as FileData[];
}
},
{
immediate: true,
deep: true,
// once: true,
},
);

watch(
() => toValue(credentialsConfig),
(newCredentialsConfig) => {
Object.assign(credentials, newCredentialsConfig);
},
{
immediate: true,
deep: true,
},
);

// 获取文件上传token => oss上传凭据
const getOssToken = async () => {
try {
const data = await tokenApi?.();
const { accessKeyId, accessKeySecret, securityToken, expiration, bucket, region, path } =
data || {};
Object.assign(credentials, {
accessKeyId,
accessKeySecret,
securityToken,
expiration,
bucket,
region,
path,
});
} catch (err) {
console.log('err', err);
}
};

// 检查oss的token的过期时间
const checkTokenExpiration = () => {
if (credentials.expiration) {
const currentTime = new Date().getTime();
const expirationTime = new Date(credentials.expiration).getTime();
const timeUntilExpiration = expirationTime - currentTime;

// 如果距离过期时间小于 2 分钟(120000 毫秒),则重新请求 Token
if (timeUntilExpiration <= 120000) {
getOssToken();
}
}
};

const setTimerCheckToken = () => {
timerId = setInterval(checkTokenExpiration, intervalCheckToken); // 每分钟检查一次
};

const clearTimerByCheckToken = () => {
if (timerId) {
window.clearInterval(timerId);
timerId = null;
}
};

// 计算md5
const calculateMD5 = (file, chunkSize = 2 * 1024 * 1024) => {
return new Promise((resolve, reject) => {
const spark = new SparkMD5.ArrayBuffer();
const fileReader = new FileReader();
let offset = 0;

fileReader.onload = (e: any) => {
spark.append(e.target.result);
offset += chunkSize;
if (offset < file.size) {
loadNext();
} else {
const md5 = spark.end();
resolve(md5);
}
};

fileReader.onerror = (e) => {
reject(e);
};

function loadNext() {
const blob = file.slice(offset, offset + chunkSize);
fileReader.readAsArrayBuffer(blob);
}

loadNext();
});
};

// 一键上传 (批量上传) (分片上传)
const multipartUpload = async () => {
cancel = false;
if (uploading.value) {
return; // 如果正在上传,不执行上传操作
}
uploading.value = true; // 设置上传状态为正在上传
disabledUpload.value = true;
const uploadQueue = map(fileInfoList.value, (item) => {
return setItemOSSClient(item);
});

let currentIndex = 0; // 队列指针
let activeUploads = 0; // 进行中任务数

const startNextUpload = async () => {
if (currentIndex >= uploadQueue.length && activeUploads === 0) {
uploading.value = false; // 上传完成,恢复按钮状态
disabledUpload.value = false;
onComplete?.();
return;
}

while (activeUploads < batchSize && currentIndex < uploadQueue.length) {
const item = uploadQueue[currentIndex];
currentIndex++;
activeUploads++;

ossUpload(item).then(() => {
activeUploads--; // 当完成一次上传时, 补充下一个新任务
startNextUpload(); // 递归调用,继续上传下一个素材
});
}
};

await startNextUpload();
};

// 分片上传
const ossUpload = async (item) => {
const { percentage = 0 } = item;
if (percentage < 100) {
try {
const path = credentials.path + item.name;
// 上传中状态
set(item, 'statusType', UploadStatusEnum.UPLOADING);
onProgress?.({ item, percentage: 0 });
const data = await item.client.multipartUpload(path, item.file, {
parallel: 5,
partSize: partSize,
progress: async (p, checkpoint, res) => {
await onUploadProgress(item, p, checkpoint, res, path);
if (cancelMap[item.id]) {
item.client.cancel();
} else {
if (cancel) {
item.client.cancel();
}
}
},
timeout, // 设置超时时间
});
const requestUrls = get(data, ['res', 'requestUrls']);
const curFileIndex = findIndex(
fileInfoList.value,
(fileItem: ObjectType) => fileItem.id === item.id,
);
const url = head(requestUrls) || item.url;
fileList.value = multiple ? uniqBy([...(fileList.value || []), item], 'id') : [item];
onSuccess?.({
item: { ...item, url },
res: data,
url,
fileIndex: curFileIndex,
});
} catch (err) {
set(item, 'statusType', UploadStatusEnum.ERROR);
const curFileIndex = findIndex(
fileInfoList.value,
(fileItem: ObjectType) => fileItem.id === item.id,
);
// 触发错误回调
onError?.({
item,
error: err,
fileIndex: curFileIndex,
isCancel: cancelMap[item.id],
});
} finally {
remove(fileInfoList.value, (fileItem: ObjectType) => fileItem.id === item.id);
}
}
};

const setItemOSSClient = (item) => {
let newItem = cloneDeep(item);
newItem.client = new OSS({
accessKeyId: credentials.accessKeyId,
accessKeySecret: credentials.accessKeySecret,
stsToken: credentials.securityToken,
bucket: credentials.bucket,
region: credentials.region,
endpoint: envConfig.oss_endpoint, // 自定义oss上传加速域名
cname: true,
refreshSTSToken: async () => {
// 刷新临时访问凭证的回调,在上传大文件的时候会用到
await getOssToken();
const { accessKeyId, accessKeySecret, securityToken } = credentials;
return {
accessKeyId,
accessKeySecret,
stsToken: securityToken,
};
},
refreshSTSTokenInterval,
});
return newItem as FileData;
};

// 单个文件上传
const ossUploadByPut = async () => {
if (uploading.value) {
return;
}
const item = last(fileInfoList.value);
const ossItem = setItemOSSClient(item);
if (ossItem) {
const path = `${credentials.path}${ossItem.name}`;
try {
uploading.value = true;
disabledUpload.value = true;
onProgress?.({ item: ossItem, percentage: 0 });
const res = await ossItem.client.put(path, ossItem.file);
const url = get(res, 'url');
const curFileIndex = findIndex(
fileInfoList.value,
(fileItem: ObjectType) => fileItem.id === item?.id,
);
set(fileInfoList.value, [curFileIndex, 'url'], url);
fileList.value = multiple ? uniqBy([...(fileList.value || []), item], 'id') : [item];
onSuccess?.({
item: ossItem,
res,
url,
fileIndex: curFileIndex,
});
onProgress?.({ item: ossItem, percentage: 100 });
} catch (err) {
set(ossItem, 'statusType', UploadStatusEnum.ERROR);
console.error('OSS PUT上传失败:', err);
const curFileIndex = findIndex(
fileInfoList.value,
(fileItem: ObjectType) => fileItem.id === item?.id,
);
// 触发错误回调
onError?.({
item: ossItem,
error: err,
fileIndex: curFileIndex,
isCancel: cancelMap[ossItem.id],
});
if (multiple) {
fileList.value = fileList.value.filter((file: ObjectType) => file.id !== item?.id);
} else {
fileList.value = [];
}
} finally {
uploading.value = false;
disabledUpload.value = false;
remove(fileInfoList.value, (fileItem: ObjectType) => fileItem.id === item?.id);
}
}
};

// 上传进度
const onUploadProgress = async (item, p, checkpoint, res, path) => {
if (checkpoint) {
item.speed = handle_network_speed(res, partSize, p);
item.upload = checkpoint.uploadId;
item.url = res.requestUrls[0];
}
const percentage = Number((p * 100).toFixed(2));
item.isPlay = true;
item.uploadName = path;
// 上传进度
item.percentage = percentage;
// 改变上传状态
item.statusType = percentage === 100 ? UploadStatusEnum.SUCCESS : UploadStatusEnum.UPLOADING;
onProgress?.({ item, percentage: item.percentage });
};

// 更新数据
const updateFileData = ({ file, md5 }: { file: FileType; md5?: string }) => {
const fileInfo = {
name: file.name,
size: filterSize(file.size),
type: file.type,
md5: md5,
file: file,
id: file.uid,
};
// 多选情况
if (multiple) {
// name唯一情况
if (uniqFlag === 'name') {
const curNameIndex = findIndex(fileInfoList.value, (info) => {
if (typeof config?.matchNameRuleFn === 'function') {
return (
config?.matchNameRuleFn?.(info.name) === config?.matchNameRuleFn?.(fileInfo.name)
);
}
return info.name === fileInfo.name;
});
// 如果存在name,则替换
if (curNameIndex > -1) {
fileInfoList.value.splice(curNameIndex, 1, fileInfo);
fileList.value.splice(curNameIndex, 1, fileInfo);
} else {
fileInfoList.value.push(fileInfo);
fileList.value.push(fileInfo);
}
} else {
const hasFile = find(fileInfoList.value, (info) => info.md5 === fileInfo.md5);
if (!hasFile) {
fileInfoList.value.push(fileInfo); // 将计算结果存储到缓存数组中 (列表展示)
}
}
} else {
fileInfoList.value = [fileInfo];
}

if (!isDirectUploadOss) {
fileList.value = multiple ? uniqBy([...(fileList.value || []), fileInfo], 'id') : [fileInfo];
}
};

/**
* 上传文件之前的钩子 => 参数为上传的文件,若返回 false 则停止上传
* 检查文件是否已经上传过
*/
const beforeUpload = async (
file: FileType,
): Promise<string | boolean | void | FileType | Blob> => {
disabledUpload.value = true;

if (onBeforeUpload && typeof onBeforeUpload === 'function') {
const result = await onBeforeUpload?.(file);
if (!result) {
uploading.value = false;
disabledUpload.value = false;
return false;
}
}

if (file.size > limitSize) {
message.error(limitMessage);
uploading.value = false;
disabledUpload.value = false;
return false; // 阻止文件上传
}

if (isCalculateMd5) {
return new Promise((resolve, reject) => {
calculateMD5(file)
.then((md5: string) => {
updateFileData({ file, md5 });
resolve(file);
})
.catch((error) => {
reject(error);
});
});
} else {
updateFileData({ file });
}
};

const customRequest = async () => {
try {
onCustomRequest?.({ fileList: fileList.value });
// 上传前,调用接口检验
if (isCheck && checkApi && typeof checkApi === 'function') {
await checkApi?.({ fileInfoList });
}
if (isDirectUploadOss) {
// 分片上传
if (isMultipartUpload) {
await multipartUpload();
} else {
// 直接上传
await ossUploadByPut();
}
}
} catch (err) {
console.log('err', err);
} finally {
// fileList.value = [];
}
};

const handleDeleteItem = (item) => {
fileInfoList.value = filter(fileInfoList.value, (file) => file.id !== item.id);
fileList.value = filter(fileList.value, (file) => file.id !== item.id);
};

const handleClear = () => {
fileInfoList.value = [];
fileList.value = [];
};

// 取消上传
const uploadCancel = (id?) => {
if (id) {
Object.assign(cancelMap, {
[id]: true,
});
} else {
cancel = true;
}
};

return {
disabledUpload,
fileList,
fileInfoList,
calculateMD5,
multipartUpload,
getOssToken,
ossUploadByPut,
beforeUpload,
customRequest,
checkTokenExpiration,
clearTimerByCheckToken,
setTimerCheckToken,
timerId,
uploading,
handleDeleteItem,
handleClear,
uploadCancel,
};
}

使用​

js
const {
fileList,
uploading,
customRequest,
beforeUpload,
getOssToken,
multipartUpload,
handleClear,
uploadCancel,
} = useOssUpload({
// credentialsConfig: credentials,
config: {
limitSize: 400 * 1024 * 1024, // 限制400M
limitMessage: '单个视频文件大小,不能超过400M!',
isDirectUploadOss: false, // 不直接上传到oss
batchSize: 5, // 并发数
},
onBeforeUpload: (file) => {
return true;
},
onSuccess: async ({ item }) => {
},
onError: ({ item }) => {
},
onProgress: ({ percentage, item }) => {
},
});