最近一段时间领导让我跟踪研究一下云服务系统的文件上传功能。问题的背景是,①文件一旦超过100M以后上传耗时就变得很长;②超过500M以后出错的几率大大增加,用户体验极其不友好。
已知旧版本在这一块使用的ota上传功能采用的是阿里的upload.js + plupload插件实现的。
本篇优化使用STS,web端从后台获取临时授权token,调有用aliyun-oss-sdk api来实现。
<dependency>
<groupId>com.aliyungroupId>
<artifactId>aliyun-java-sdk-stsartifactId>
<version>2.1.6version>
dependency>
<dependency>
<groupId>com.aliyungroupId>
<artifactId>aliyun-java-sdk-coreartifactId>
<version>3.2.10version>
dependency>
什么是oss ?
阿里云对象存储服务(Object Storage Service,简称OSS),是阿里云对外提供的海量、安全、低成本、高可靠的云存储服务。我们在上传文件这一功能方面,总不可避免会遇到文件太大上传太慢乃至失败问题,oss就是阿里对该问题提供的解决方案。
更详细介绍
参考阿里云链接:oss产品简介
中文 | 英文 | 说明 |
---|---|---|
存储空间 | bucket | 存储空间是您用于存储对象(Object)的容器,所有的对象都必须隶属于某个存储空间。对应到阿里云上就是你的bucket桶空间,用来存放文件等资源。 |
对象/文件 | Object | 对象是 OSS 存储数据的基本单元,也被称为OSS的文件。对象由元信息(Object Meta)、用户数据(Data)和文件名(Key)组成。对象由存储空间内部唯一的Key来标识。 |
地域 | Region | 地域表示 OSS 的数据中心所在物理位置。您可以根据费用、请求来源等综合选择数据存储的地域。详情请查看OSS已经开通的Region。 |
访问域名 | Endpoint | Endpoint 表示OSS对外服务的访问域名。OSS以HTTP RESTful API的形式对外提供服务,当访问不同地域的时候,需要不同的域名。通过内网和外网访问同一个地域所需要的域名也是不同的。具体的内容请参见各个Region对应的Endpoint |
访问密钥 | AccessKey | AccessKey,简称 AK,指的是访问身份验证中用到的AccessKeyId 和AccessKeySecret。OSS通过使用AccessKeyId 和AccessKeySecret对称加密的方法来验证某个请求的发送者身份。AccessKeyId用于标识用户,AccessKeySecret是用户用于加密签名字符串和OSS用来验证签名字符串的密钥,其中AccessKeySecret 必须保密。 |
(PS: 我们本篇介绍的上传方式就要用到上述术语)
有很多,比如 Java、Node.js、Browser.js、等等。
重点介绍一下与本篇相关的sdk。
(我是优化的,所以我司服务器已有一堆bucket,这里贴出来方法)
登录云账户,搜索框输入 对象存储OSS跳转进入页面,点击右侧,创建bucket。
点击新建/已有的 bucket名称进入,设置跨域规则。
这里,照着操作就OK:
!!! AccessKey Secret 只在第一次创建时可见!记不住丢了后面可别找我要哦!!!
进入RAM访问控制——>用户——>创建用户。勾选 api调用,名称随意(假设命名为STS)。
创建好后,要给它分配权限:
点击名称进入详情——>查看权限管理——>新增授权,我们给它加一个支持调用阿里STS服务 AssumeRole接口的权限,这样后台才可以凭借该用户身份分配STS token给前端。
点击认证管理,我们要牢牢记住该用户的 AccessKeyId 和 AccessKey Secret ,尤其secret,只在创建用户时可见,之后忘了是无法找到的。
我们还需要在RAM控制页面 新建一个角色并分配权限。
作用:使得前端 有权限调用aliyun-oss-sdk进行文件上传(写入桶空间)。
点击创建角色,这里我命名为:AliyunRAM-OSSRole,你们随意。
角色即我们上面的角色名。创建完成。
然后点击名称进入,我们还得分配权限,什么权限呢?
当然是以上两个针对OSS管理/访问的系统策略咯~
注:还可以自定义权限策略并分配给该角色,我偷懒了,没有,在这里贴出来别人的一份自定义策略,只涉及上传相关的权限,可用性未知。
{
"Version": "1",
"Statement": [
{
"Effect": "Allow",
"Action": [
"oss:PutObject",
"oss:InitiateMultipartUpload",
"oss:UploadPart",
"oss:UploadPartCopy",
"oss:CompleteMultipartUpload",
"oss:AbortMultipartUpload",
"oss:ListMultipartUploads",
"oss:ListParts"
],
"Resource": [
"acs:oss:*:*:mudontire-test",
"acs:oss:*:*:mudontire-test/*"
]
}
]
}
哈哈,到这里我们的一堆配置就OK了,接下来就是我们喜闻乐见的代码环节了!
在你的config.properties(名字按你的来)中,配置如下:
oss.access_id=xxx
oss.access_secret=xxx
oss.bucket_name=xxx
oss.region=xxx
你看这里就用到专业术语了吧,不会去复习~
解释:
① MDMProperies 是我的配置类,读取配置信息。
② 关于 roleArn,你在上面创建的角色基本信息里就可以看到。
③关于roleSessionName ,好像可以随意命名,记不清了,你这里跟我一样得了。(貌似为空不行)
④ 临时token有效时间 900s <= token <= 3600s
⑤ 注意 AssumeRoleRequest,jar版本不同,调用也不同。我这里是
就是我前言放的配置!
@Component
public class OssGetStsToken {
private static final Logger logger = LoggerFactory.getLogger(OssGetStsToken.class);
private static String accessKeyId = MDMProperies.ossBigAccessId;
private static String accessKeySecret = MDMProperies.ossBigAccessSecret;
private static final String roleArn = "acs:ram::xxx/aliyunram-ossrole";
private static final String roleSessionName = "alice";
private static final String bucketName = MDMProperies.ossBigBucketName;
private static final String region = MDMProperies.ossBigRegion;
/**
* 临时授权有效时间900-3600s,token失效时间, 单位秒
*/
private static final Long durationSeconds = 900L;
// private static final String ENDPOINT = MDMProperies.ossEndpoint;
private static final String ENDPOINT = "sts.aliyuncs.com";
/**
* 获取STStoken接口
*/
public static StsTokenVO getStsToken() {
StsTokenVO tokenVO = new StsTokenVO();
try {
// 添加endpoint(直接使用STS endpoint,前两个参数留空,无需添加region ID)
DefaultProfile.addEndpoint("", "", "Sts", ENDPOINT);
// 进行角色授权 构造default profile(参数留空,无需添加region ID)
IClientProfile profile = DefaultProfile.getProfile("", accessKeyId, accessKeySecret);
// 用profile构造client
DefaultAcsClient client = new DefaultAcsClient(profile);
final AssumeRoleRequest request = new AssumeRoleRequest();
request.setMethod(MethodType.POST);
request.setRoleArn(roleArn); // role-Arn
request.setRoleSessionName(roleSessionName);
request.setDurationSeconds(durationSeconds); // 3600s
// 针对该临时权限可以根据该属性赋予规则,格式为json,没有特殊要求,默认为空
// request.setPolicy(policy); // Optional
final AssumeRoleResponse response = client.getAcsResponse(request);
AssumeRoleResponse.Credentials credentials = response.getCredentials();
tokenVO.setAccessKeyId(credentials.getAccessKeyId());
tokenVO.setAccessKeySecret(credentials.getAccessKeySecret());
tokenVO.setSecurityToken(credentials.getSecurityToken());
tokenVO.setBucketName(bucketName);
tokenVO.setRegion(region);
tokenVO.setExpiration(durationSeconds);
logger.info("tokenVO——> :"+tokenVO);
return tokenVO;
} catch (ClientException e) {
logger.error("获取阿里云STS临时授权权限失败,错误信息:"+e);
throw new RuntimeException("获取阿里云STS临时授权权限失败,错误信息:" +e);
}
}
}
StsTokenVO:
@Data
public class StsTokenVO implements Serializable {
/**
* 访问密钥标识
*/
private String accessKeyId;
/**
* 访问密钥
*/
private String accessKeySecret;
/**
* 安全令牌
*/
private String securityToken;
/**
* oss-bucket 桶名称
*/
private String bucketName;
/**
* token过期时间
*/
private Long expiration;
/**
* 桶所在的区域
*/
private String region;
}
然后剩余的 service啦、控制层接口之类的这里就省略了。因项目而异。
后端传给前端的就是 上面的StsTokenVO的json数据。
那么我们来看前端:
<div class="row">
<div id="up_wrap" style="margin-left: 420px;width: 800px;"></div>
<div class="col-sm-offset-5 col-sm-10">
<button type="button" class="btn btn-sm btn-warning" id="pause"><i class="fa fa-close"> </i> <label class="language_label">Pause</label></button>
<button type="button" class="btn btn-sm btn-success" id="resume"><i class="fa fa-cloud-upload"> </i> <label class="language_label">Resume upload</label></button>
<button type="button" class="btn btn-sm btn-primary" id="submit"><i class="fa fa-check"></i><label class="language_label">Save</label></button>
<button type="button" class="btn btn-sm btn-danger" id="closeSubmit" onclick="closeItem()"><i class="fa fa-reply-all"></i><label class="language_label">Close</label> </button>
</div>
</div>
样式如下(关闭按钮按照你们自己的逻辑来):
哦对了,还有个文件上传选择器:
<div id="firmware" class="input-group">
<input type="file" class="form-control" id="fileName" multiple="true">
</div>
关于这个type=file 原生的太丑的问题,我另开一篇介绍美化。
Script引入:
<script type="text/javascript"
src="https://gosspublic.alicdn.com/aliyun-oss-sdk-6.16.0.min.js"></script>
<script type="text/javascript">
剩余代码:
var prefix = ctx + "biz/firmware_ota";
var getStsTokenURL = ctx + 'biz/sts_token!token.action';
var bucket = ""; // bucket 桶名称 (初始化client时获取)
var region = ''; // oss bucket所在地域名称
let ossClient = null; // 定义 oss客户端实例变量
let credentials = null; // 变量接收stsToken
let tempCheckPoint = null; //数组,记录了已经完成上传的分片及其对应的etag
const checkpoints = {};
// 定义中断点
//let abortCheckPoint;
// 获取上传DOM。
const submit = document.getElementById("submit");
// 获取中断dom
const pause = document.getElementById("pause");
// 获取续传dom
const resume = document.getElementById("resume");
// 获取STS Token
function getCredential() {
return fetch(getStsTokenURL)
.then(res => {
return res.json()
})
.then(res => {
console.log(JSON.stringify(res)); // 转为json字符串
credentials = res.value;
console.log("credentials:"+credentials);
})
.catch(err => {
console.error(err);
});
}
// 客户端初始化
async function initOSSClient() {
const { accessKeyId, accessKeySecret, securityToken, bucketName } = credentials;
bucket = credentials["bucketName"];
region = credentials["region"];
//console.log("bucket = "+bucket);
ossClient = new OSS({
accessKeyId: accessKeyId,
accessKeySecret: accessKeySecret,
stsToken: securityToken,
//secure:true,
bucket: bucketName,
region
});
}
const headers = {
// 指定该Object被下载时的网页缓存行为。
"Cache-Control": "no-cache",
// 指定该Object被下载时的名称。
//"Content-Disposition": "example.txt",
// 指定该Object被下载时的内容编码格式。
"Content-Encoding": "utf-8",
// 指定过期时间,单位为毫秒。
//Expires: "1000",
"Access-Control-Allow-Origin": "*",
// 指定Object的存储类型。
//"x-oss-storage-class": "Standard",
// 指定Object标签,可同时设置多个标签。
"x-oss-tagging": "Tag1=1&Tag2=2",
// 指定初始化分片上传时是否覆盖同名Object。此处设置为true,表示禁止覆盖同名Object;存在相同会报错
"x-oss-forbid-overwrite": "true",
"Content-Type": 'application/x-www-form-urlencoded'
};
const options = {
// 获取分片上传进度、断点和返回值。
progress: (p, cpt, res) => {
tempCheckPoint = cpt;
console.log(p);
//console.log(tempCheckPoint); // 测试输出part和etag
},
// 设置并发上传的分片数量。
parallel: 4,
// 设置分片大小。默认值为1 MB,最小值为100 KB。
partSize: 1024 * 1024 * 1, // 设为1MB
headers,
// 自定义元数据,通过HeadObject接口可以获取Object的元数据。
//meta: { year: 2020, people: "test" },
mime: "text/plain",
timeout: 120000 // 设置超时时间
};
// 普通上传
async function commonUpload(file) {
if (!ossClient) {
await initOSSClient();
}
const fileName = file.name;
$("#fileLength").val(file.size);
return ossClient.put(fileName, file, {headers: { 'x-oss-forbid-overwrite': true }}).then(result => {
console.log(`Common upload ${file.name} succeeded, result === `, result)
const url = `https://${bucket}.${region}.aliyuncs.com/${fileName}`;
// 获取下载文件的url
$("#firmwareUrl").val(url);
$.operate.saveTab(prefix + "!doAddInfo.action", $('#form-info-add').serialize()); // 保存上传记录到后台数据库
console.log("save success...");
}).catch(err => {
console.log(`Common upload ${file.name} failed === `, err);
});
}
// 分片上传
let retryCount = 0;
let retryCountMax = 3;
const uploadFile = function uploadFile(client) {
if (!ossClient || Object.keys(ossClient).length === 0) {
ossClient = client;
}
}
async function multipartUpload(file) {
if (!ossClient) {
await initOSSClient();
}
const fileName = file.name;
$("#fileLength").val(file.size);
return ossClient.multipartUpload(fileName, file, {
parallel: options.parallel,
partSize: options.partSize,
progress: onMultipartUploadProgress,
headers,
timeout: 120000 // 设置超时时间2min
}).then( result => {
console.log("upload success: ", result);
const url = `https://${bucket}.${region}.aliyuncs.com/${fileName}`;
console.log(`Multipart upload file ${file.name} success, url = `, url);
$("#firmwareUrl").val(url); // 赋值url
$.operate.saveTab(prefix + "!doAddInfo.action", $('#form-info-add').serialize()); // 保存上传记录到后台数据库,我自己的需求。这一步看你们需求,只上传就没必要
console.log("save success...");
ossClient = null;
}).catch(err => {
if (ossClient && ossClient.isCancel()) {
console.log("stop-upload!");
} else {
console.log(checkpoints);
console.log(`Multipart upload ${file.name} failed === `, err);
// retry 重试机制 3次
if (retryCount < retryCountMax) {
retryCount++;
console.error("retryCount: " + retryCount);
uploadFile('');
}
}
})
}
var oldDate = null;
// 分片上传进度改变回调
async function onMultipartUploadProgress(progress, checkpoint) {
//console.log(`${checkpoint.file.name} 上传进度 ${progress}`);
checkpoints[checkpoint.uploadId] = checkpoint;
let ps = parseInt((progress.toFixed(2)) * 100);
//console.log("上传进度:", ps + '%');
//console.log("cpt:", checkpoint);
let html = '';
html = '
';
$("#up_wrap").html(html);
// 判断STS Token是否将要过期,过期则重新获取
const { expiration } = credentials;
//console.log("token 过期时间:"+expiration);
//console.log("oldDate:"+oldDate);
if (oldDate === null) {
oldDate = new Date().getTime() + (expiration * 1000); //
localStorage.setItem('tokenTime', oldDate);
//console.log("tokenTime:"+oldDate);
}
let tokenTime = localStorage.getItem("tokenTime");
//console.log("localStorage tokenTime:"+tokenTime);
if (tokenTime !== null){
const tempTime = 60 * 1000;
if ((tokenTime - new Date().getTime()) <= tempTime) { // 距离token过期小于1min时暂停上传重新获取token后再续传
console.log(`STS token will expire in ${tempTime/(60*1000)} minutes,uploading will pause and resume after getting new STS token`);
if (ossClient) {
ossClient.cancel();
}
await getCredential();
await resumeMultipartUpload();
}
}
// 断点续传
async function resumeMultipartUpload() {
Object.values(checkpoints).forEach((checkpoint) => {
const { uploadId, file, name } = checkpoint;
console.log("uploadId:"+uploadId);
console.log("file:"+file);
console.log("name:"+name);
ossClient.multipartUpload(uploadId, file, {
parallel: options.parallel,
partSize: options.partSize,
progress: onMultipartUploadProgress,
checkpoint
}).then(result => {
console.log('before delete checkpoints === ', checkpoints);
delete checkpoints[checkpoint.uploadId];
console.log('after delete checkpoints === ', checkpoints);
const url = `https://${bucket}.${region}.aliyuncs.com/${name}`;
console.log(`Resume multipart upload ${file.name} succeeded, url === `, url)
$("#firmwareUrl").val(url);
$.operate.saveTab(prefix + "!doAddInfo.action", $('#form-info-add').serialize()); // 保存上传记录到后台数据库
console.log("save success...");
}).catch(err => {
console.log('Resume multipart upload failed === ', err);
});
});
}
submit.addEventListener("click", async () => {
try {
const file = document.getElementById("fileName").files[0];
//console.log("data=" +data);
//采用时间戳重命名
var last=file.name.substr(file.name.lastIndexOf("."),file.name.length)
var fileName=Date.parse(new Date()) + last;
console.log("file name:"+file.name);
console.log("submit filename="+fileName);
console.log("file Size: "+file.size);
console.log("file Type: "+file.type);
// 获取STS Token
await getCredential();
await initOSSClient();
// 如果文件大小小于分片大小,使用普通上传,否则使用分片上传
if (file.size < options.partSize) {
console.log("普通上传...");
await commonUpload(file);
} else {
console.log("大于100M大文件分片上传...");
await multipartUpload(file);
}
} catch (err) {
console.log(err);
}
});
pause.addEventListener("click", () => {
// 暂停上传
if (ossClient) ossClient.cancel();
console.log("暂停上传......");
})
// 监听续传按钮,单击“恢复上传”后继续上传
resume.addEventListener("click", async () => {
console.log("断点续传中......");
await resumeMultipartUpload();
})
2022.11.10 再次更新1条:
if ((tokenTime - new Date().getTime()) <= tempTime) { // 距离token过期小于1min时暂停上传重新获取token后再续传
console.log(`STS token will expire in ${tempTime/(60*1000)} minutes,uploading will pause and resume after getting new STS token`);
if (ossClient) {
ossClient.cancel();
}
await getCredential();
await resumeMultipartUpload();
}
异步获取后台新的stsToken失败,出现错误,调试web发现并没有获取到新的token。修改无果,最终决定改掉这个逻辑,发现ali-oss有更简便的方式来实现自动重新获取stsToken 。(PS:我上面的方式也是参考网上资料的,是通过获取后台token携带的有效期在web端判断1min内过期然后人为再次调用获取token的getCredential方法。但是实际上行不通,应该是异步方法并没有真正的异步执行吧,然后因为是在进度改变回调方法里,感觉不伦不类的。(doge))
!!!那么重点来了,以我上述程序为基础,修改部分如下:
① 去掉onMultipartUploadProgress方法里的判断过期重获token这一段代码:
// 判断STS Token是否将要过期,过期则重新获取
const { expiration } = credentials;
//console.log("token 过期时间:"+expiration);
//console.log("oldDate:"+oldDate);
if (oldDate === null) {
oldDate = new Date().getTime() + (expiration * 1000); //
localStorage.setItem('tokenTime', oldDate);
//console.log("tokenTime:"+oldDate);
}
let tokenTime = localStorage.getItem("tokenTime");
//console.log("localStorage tokenTime:"+tokenTime);
if (tokenTime !== null){
const tempTime = 60 * 1000;
if ((tokenTime - new Date().getTime()) <= tempTime) { // 距离token过期小于1min时暂停上传重新获取token后再续传
console.log(`STS token will expire in ${tempTime/(60*1000)} minutes,uploading will pause and resume after getting new STS token`);
if (ossClient) {
ossClient.cancel();
}
await getCredential();
await resumeMultipartUpload();
}
}
② 修改初始化 oss客户端的方法:
// 初始化oss客户端增加token过期重新获取
async function initOSSClient() {
const { accessKeyId, accessKeySecret, securityToken, bucketName } = credentials;
bucket = credentials["bucketName"];
region = credentials["region"];
//console.log("bucket = "+bucket);
ossClient = new OSS({
accessKeyId: accessKeyId,
accessKeySecret: accessKeySecret,
stsToken: securityToken,
secure:true,
bucket: bucketName,
region,
refreshSTSToken: async () => {
await getCredential();
//console.log("credentials = "+JSON.stringify(credentials))
return {
accessKeyId: credentials["accessKeyId"],
accessKeySecret: credentials["accessKeySecret"],
stsToken: credentials["securityToken"],
}
},
refreshSTSTokenInterval: 900 * 1000 // 单位ms,这里设置15min自动获取新的token
});
}
这两个参数是sdk提供的,所以根本不需要我们自己主动重新获取,只要设置好refreshSTSTokenInterval,然后就不用管了。这才是简便易用的打开方式!已经过实测,超过15min会获取新的token,各位实测的时候取消console注释,上传过程中断网,超过有效期再联网恢复上传,web控制台会有输出console内容。文件顺利续传成功。
DurationSeconds 默认最大是 3600s 也就是一小时,如果要超过一小时,需要设置 CreateRole或UpdateRole 接口中MaxSessionDuration 的时间
===================================================================================