通过服务端签名实现鸿蒙应用直传OSS使用户可以通过鸿蒙端直接使用PutObject接口上传文件到OSS,此过程通过在服务端实现URL签名机制确保上传的安全性。
方案概览
使用鸿蒙环境实现文件上传的过程如下:
要实现鸿蒙应用直传OSS,只需3步:
配置OSS:在OSS控制台上创建一个Bucket,用于存储用户上传的文件。
配置服务端:在服务端创建一个实例,用于从STS服务获取一个临时访问凭证,然后使用临时访问凭证生成签名 URL用于授权用户在一定时间内进行文件上传。
配置鸿蒙客户端:在鸿蒙客户端,实现从ECS获取签名并构造PutObject请求,将文件上传到OSS。
示例代码
以下代码示例只给出关键逻辑的代码片段,完整示例工程请参见:oss-js-sdk-harmony-demo.zip
服务端代码示例
服务端生成签名URL:
const express = require("express");
const mime = require("mime");
const OSS = require("ali-oss");
const app = express();
const port = 3000; // 监听端口
app.use(express.json());
app.use(express.urlencoded({ extended: false }));
app.post("/get_sign_url", async (req, res) => {
const {
fileName,
method,
headers = {},
queries = {},
additionalHeaders = [],
} = req.body; // 从body中解析出数据
const client = new OSS({
region: "yourRegion",
accessKeyId: "yourstsAccessKey",
accessKeySecret: "yourstsAccessKeySecret",
stsToken: "yourSTSToken",
bucket: "yourBucket",
authorizationV4: true,
});
const reqHeaders = {
...headers,
};
// 处理一下content-type
if (fileName && method === "PUT") {
const fileNameSplit = fileName.split(".");
reqHeaders["content-type"] = mime.getType(
fileNameSplit.length > 1 ? fileNameSplit[fileNameSplit.length - 1] : ""
);
}
// 生成V4签名URL
const url = await client.signatureUrlV4(
method,
300,
{
headers: reqHeaders,
queries,
},
fileName,
additionalHeaders
);
res.json({
url,
contentType: reqHeaders["content-type"],
});
});
app.listen(port, () => {
console.log(`Example app listening on port ${port}`);
});
鸿蒙客户端代码示例
在客户端获取签名并使用签名URL上传文件:
import { http } from '@kit.NetworkKit';
import fs from '@ohos.file.fs';
import { request } from './request';
const serverUrl = 'http://x.x.x.x:3000/get_sign_url'; // 获取签名URL的服务器URL
/**
* getSignUrl返回数据
*/
export interface ISignUrlResult {
/** 签名URL */
url: string;
/** content-type */
contentType?: string;
}
/**
* 获取签名URL
* @param fileName 文件名称
* @param req 用于生成V4签名URL的请求信息
* @param req.method 请求方式
* @param [req.headers] 请求头
* @param [req.queries] 请求查询参数
* @param [req.additionalHeaders] 加签的请求头
*/
const getSignUrl = async (fileName: string, req: {
method: 'GET' | 'POST' | 'PUT';
headers?: Record<string, string | number>;
queries?: Record<string, string>;
additionalHeaders?: string[];
}): Promise<ISignUrlResult> => {
console.info('in getSignUrl');
try {
const response = await request(serverUrl, {
method: http.RequestMethod.POST,
header: {
'Content-Type': 'application/json'
},
extraData: {
fileName,
method: req.method,
headers: req.headers,
queries: req.queries,
additionalHeaders: req.additionalHeaders
},
expectDataType: http.HttpDataType.OBJECT
}, 200);
const result = response.result as ISignUrlResult;
console.info('success getSignUrl');
return result;
} catch (err) {
console.info('getSignUrl request error: ' + JSON.stringify(err));
throw err;
}
};
/**
* PutObject
* @param fileUri 文件URI
*/
const putObject = async (fileUri: string): Promise<void> => {
console.info('in putObject');
const fileInfo = await fs.open(fileUri, fs.OpenMode.READ_ONLY);
const fileStat = await fs.stat(fileInfo.fd);
let signUrlResult: ISignUrlResult;
console.info('file name: ', fileInfo.name);
try {
// 获取PutObject的签名URL
signUrlResult = await getSignUrl(fileInfo.name, {
method: 'PUT',
headers: {
'Content-Length': fileStat.size
},
additionalHeaders: ['Content-Length']
});
} catch (e) {
await fs.close(fileInfo.fd);
throw e;
}
const data = new ArrayBuffer(fileStat.size);
await fs.read(fileInfo.fd, data);
await fs.close(fileInfo.fd);
try {
// 使用PutObject方法上传文件
await request(signUrlResult.url, {
method: http.RequestMethod.PUT,
header: {
'Content-Length': fileStat.size,
'Content-Type': signUrlResult.contentType
},
extraData: data
}, 200);
console.info('success putObject');
} catch (err) {
console.info('putObject request error: ' + JSON.stringify(err));
throw err;
}
};
export {
getSignUrl,
putObject
};
操作步骤
步骤一:配置OSS
创建Bucket
创建一个OSS Bucket,用于存储Web应用在浏览器环境中直接上传的文件。
登录OSS管理控制台。
在左侧导航栏,单击Bucket 列表,然后单击创建 Bucket。
在创建 Bucket面板,选择快捷创建,按如下说明配置各项参数。
参数
示例值
Bucket名称
web-direct-upload
地域
华东1(杭州)
点击完成创建。
步骤二:配置服务端
一、创建一台ECS并绑定角色
操作一:创建ECS实例
请您进入自定义购买页面,并根据如下各模块的内容,创建或选择购买ECS实例所需的基础资源。
选择地域 & 付费类型
根据业务需求,选择合适的付费类型。本文选择按量付费模式,此模式操作相对灵活。
基于业务场景对时延的要求,选择地域。通常来说离ECS实例的物理距离越近,网络时延越低,访问速度越快。本文以选择华东1(杭州)为例。
创建专有网络VPC & 交换机
创建VPC时,请您选择和ECS相同的地域,并根据业务需求规划网段。本文以创建华东1(杭州)地域的VPC和交换机为例。创建完毕后返回ECS购买页,刷新并选择VPC及交换机。
说明创建VPC时,可同时创建交换机。
选择规格 & 镜像
选择实例的规格及镜像,镜像为实例确定安装的操作系统及版本。本文选择的实例规格为
ecs.e-c1m1.large
,在满足测试需求的同时,价格较为实惠。镜像为公共镜像Alibaba Cloud Linux 3.2104 LTS 64位
。
选择存储
为ECS实例选择系统盘,并按需选择数据盘。本文实现简单Web系统搭建,只需要系统盘存储操作系统,无需数据盘。
绑定公网IP
本实例需要支持公网访问。为了简化操作,本文选择直接为实例分配公网IP。您也可以在创建实例后,为实例绑定弹性公网IP,具体操作,请参见将EIP绑定至ECS实例。
说明若未绑定公网IP,将无法使用SSH或RDP通过公网直接访问实例,也无法通过公网验证实例中Web服务的搭建。
本文选择按使用流量的带宽计费模式。此模式只需为所消耗的公网流量付费。更多信息,请参见公网带宽计费。
创建安全组
为实例创建安全组。安全组是一种虚拟网络防火墙,能够控制ECS实例的出入流量。创建时,需要设置放行以下指定端口,便于后续访问ECS实例。
端口范围:SSH(22)、RDP(3389)、HTTP(80)、HTTPS(443)。
说明端口范围处选中的是ECS实例上运行的应用需开放的端口。
此处创建的安全组默认设置0.0.0.0/0作为源的规则。0.0.0.0/0表示允许全网段设备访问指定的端口,如果您知道请求端的IP地址,建议后续设置为具体的IP范围。具体操作,请参见修改安全组规则。
创建密钥对
密钥对可作为登录时证明个人身份的安全凭证,创建完成后,必须下载私钥,以供后续连接ECS实例时使用。创建完毕后返回ECS购买页,刷新并选择密钥对。
root
具有操作系统的最高权限,使用root
作为登录名可能会导致安全风险,建议您选择ecs-user
作为登录名。说明创建密钥对后,私钥会自动下载,请您关注浏览器的下载记录,保存
.pem
格式的私钥文件。
创建并查看ECS实例
创建或选择好ECS实例所需的基础资源后,勾选《云服务器ECS服务条款》、《云服务器ECS退订说明》,单击确认下单。在提示成功的对话框中,单击管理控制台,即可在控制台查看到创建好的ECS实例。请您保存以下数据,以便在后续操作中使用。
实例ID:便于在实例列表中查询到该实例。
地域:便于在实例列表中查询到该实例。
公网IP地址:便于在后续使用ECS实例时,做Web服务的部署结果验证。
操作二:连接ECS实例
在云服务器ECS控制台的实例列表页面,根据地域、实例ID找到创建好的ECS实例,单击操作列下的远程连接。
在远程连接对话框中,单击通过Workbench远程连接对应的立即登录。
在登录实例对话框中,选择认证方式为SSH密钥认证,用户名为
root
,输入或上传创建密钥对时下载的私钥文件,单击确定,即可登录ECS实例。说明私钥文件在创建密钥对时自动下载到本地,请您关注浏览器的下载记录,查找
.pem
格式的私钥文件。显示如下页面后,即说明您已成功登录ECS实例。
操作三:在访问控制中创建RAM角色
进入RAM访问控制的创建角色页面。
在创建角色页面勾选阿里云服务,单击下一步。
勾选普通服务角色,填写角色名称如
oss-web-upload
。并选择受信服务为云服务器后,单击完成,即可完成创建。单击复制,保存角色的ARN。
操作四:在访问控制中创建上传文件的权限策略
在权限策略页面,单击创建权限策略。
在创建权限策略页面,单击脚本编辑,将以下脚本中的
<Bucket名称>
替换为准备工作中创建的Bucket名称web-direct-upload
。{ "Version": "1", "Statement": [ { "Effect": "Allow", "Action": "oss:PutObject", "Resource": "acs:oss:*:*:<Bucket名称>/*" } ] }
然后单击确定,填写策略名称。
操作五:在访问控制为RAM角色授予权限
在角色页面,找到目标RAM角色,然后单击RAM角色右侧的新增授权。
在新增授权页面下的自定义策略页签,选择已创建的自定义权限策略。
单击确定。
操作六:为ECS绑定RAM角色
进入云服务器ECS的实例页面,在页面上方选择ECS实例所处地域,然后在实例页面单击目标实例右侧按钮,单击授予/收回RAM角色。
在授予/收回RAM角色弹出框选择目标RAM角色,完成ECS绑定RAM角色。
生成的临时身份凭证用于下一步生成签名URL,如果您已经拥有临时身份凭证,可以直接跳转到下一步三、服务端生成URL签名。
二、服务端生成临时访问凭证
操作一、ECS服务端配置依赖
请执行以下命令安装获取临时访问凭证所需要的依赖项。
Python
安装Python3。
执行以下命令,安装Credentials工具。
sudo pip install oss2
sudo pip install alibabacloud_credentials
Java
在Maven项目中,导入以下依赖。
<!-- https://mvnrepository.com/artifact/com.aliyun/credentials-java -->
<dependency>
<groupId>com.aliyun</groupId>
<artifactId>credentials-java</artifactId>
<version>0.3.4</version>
</dependency>
<dependency>
<groupId>com.aliyun.kms</groupId>
<artifactId>kms-transfer-client</artifactId>
<version>0.1.0</version>
</dependency>
<dependency>
<groupId>com.aliyun.oss</groupId>
<artifactId>aliyun-sdk-oss</artifactId>
<version>3.17.4</version>
</dependency>
操作二、在ECS服务端获取临时身份凭证
通过在业务服务器集成STS SDK,实现获取临时STS身份凭证,并将其返回给请求者。
Python
from alibabacloud_credentials.client import Client as CredClient
from alibabacloud_credentials.models import Config as CredConfig
def main():
# 配置ECSRAMRole作为访问凭证。
credentialConfig = CredConfig(
type='ecs_ram_role',
role_name='ecs_role_name' # 请填写ECS扮演的角色名称。
)
credentialsClient = CredClient(credentialConfig)
credential = credentialsClient.get_credential()
accesskeyid = credential.access_key_id # 获取accesskeyid。
accesskeysecret = credential.access_key_secret # 获取accesskeysecret。
security_token = credential.security_token # 获取security_token。
print("stsToken:", security_token)
print("accesskeyid:", accesskeyid)
print("accesskeysecret:", accesskeysecret)
if __name__ == "__main__":
main()
Java
import com.aliyun.credentials.models.CredentialModel;
import com.aliyun.oss.common.auth.Credentials;
import com.aliyun.oss.common.auth.CredentialsProvider;
import com.aliyun.oss.common.auth.DefaultCredentials;
import com.aliyun.oss.common.utils.BinaryUtil;
public class vxDemo {
public static void main(String[] args) {
// 配置ECSRAMRole作为访问凭证。
com.aliyun.credentials.models.Config config = new com.aliyun.credentials.models.Config();
config.setType("ecs_ram_role");
config.setRoleName("ecs_role_name"); // 请填写ECS扮演的角色名称。
final com.aliyun.credentials.Client credentialsClient = new com.aliyun.credentials.Client(config);
CredentialsProvider credentialsProvider = new CredentialsProvider() {
@Override
public void setCredentials(Credentials credentials) {
}
@Override
public Credentials getCredentials() {
CredentialModel credential = credentialsClient.getCredential();
return new DefaultCredentials(credential.getAccessKeyId(), credential.getAccessKeySecret(), credential.getSecurityToken());
}
};
String accessKeyId = credentialsProvider.getCredentials().getAccessKeyId(); //获取accessKeyId。
String secretAccessKey = credentialsProvider.getCredentials().getSecretAccessKey(); //获取secretAccessKey。
String securityToken = credentialsProvider.getCredentials().getSecurityToken(); //获取securityToken。
// 打印临时访问凭证信息。
System.out.println("stsToken:" + securityToken);
System.out.println("accessKeyId:" + accessKeyId);
System.out.println("accesskeySecret:"+ secretAccessKey);
}
}
生成的临时访问凭证accessKeyId、accessKeySecret、stsToken用于在下一步中填入生成签名URL。
三、服务端生成URL签名
客户端向服务端发送一个 POST 请求,包含文件名、HTTP 方法、请求头、查询参数等。服务端接收到请求后,通过OSS Client生成一个签名URL,该URL允许用户上传文件到OSS。
app.post('/get_sign_url', async (req, res) => {
const {
fileName,
method,
headers = {},
queries = {},
additionalHeaders = []
} = req.body; // 从body中解析出数据
const client = new OSS({
region: 'yourRegion',
// 填入步骤二中返回的临时访问凭证accessKeyId、accessKeySecret、stsToken
accessKeyId: 'yourstsAccessKey',
accessKeySecret: 'yourstsAccessKeySecret',
stsToken: 'yourSTSToken',
bucket: 'yourBucket',
authorizationV4: true
});
const reqHeaders = {
...headers
};
// 处理一下content-type
if (fileName && method === 'PUT') {
const fileNameSplit = fileName.split('.');
reqHeaders['content-type'] = mime.getType(fileNameSplit.length > 1 ? fileNameSplit[fileNameSplit.length-1] : '');
}
// 生成V4签名URL
const url = await client.signatureUrlV4(method, 300, {
headers: reqHeaders,
queries
}, fileName, additionalHeaders);
res.json({
url,
contentType: reqHeaders['content-type']
});
});
步骤三:配置鸿蒙客户端
一、构建HTTP数据请求
使用 @kit.NetworkKit
库的 http
模块来发送 HTTP 请求的异步函数 request
。
import { http } from '@kit.NetworkKit';
const request = async (url: string, options: http.HttpRequestOptions, successCode: number[] | number) => {
const httpRequest = http.createHttp();
try {
const httpResponse = await httpRequest.request(url, {
...options,
priority: 1,
connectTimeout: 60000,
readTimeout: 60000,
usingProtocol: http.HttpProtocol.HTTP1_1
});
if ((Array.isArray(successCode) && successCode.includes(httpResponse.responseCode)) || httpResponse.responseCode === successCode) {
const requestID = httpResponse.header['x-oss-request-id'];
console.info(`request success${requestID ? ', oss request ID: ' + requestID : ''}`);
return httpResponse;
} else {
throw {
code: httpResponse.responseCode,
result: httpResponse.result.toString(),
requestID: httpResponse.header['x-oss-request-id']
};
}
} catch (err) {
console.info('request error: ' + JSON.stringify(err));
throw err;
} finally {
httpRequest.destroy();
}
};
export {
request
};
二、从本地获取要上传的文件
import { common } from '@kit.AbilityKit';
import abilityAccessCtrl from '@ohos.abilityAccessCtrl';
import picker from '@ohos.file.picker';
// 选择文件
const fileSelect = async (context: common.Context) => {
const documentSelectOptions = new picker.DocumentSelectOptions();
const documentViewPicker = new picker.DocumentViewPicker(context);
documentSelectOptions.maxSelectNumber = 5;
const documentSelectResult = await documentViewPicker.select(documentSelectOptions);
return documentSelectResult;
};
export {
fileSelect
};
三、客户端发送请求生成签名URL
客户端发起请求到服务器请求生成签名 URL。请求体包含了文件名、请求方法、头部信息等。成功响应后,返回签名 URL 和 Content-Type
。如果请求失败,捕获并抛出错误。
const getSignUrl = async (fileName: string, req: {
method: 'GET' | 'POST' | 'PUT';
headers?: Record<string, string | number>;
queries?: Record<string, string>;
additionalHeaders?: string[];
}): Promise<ISignUrlResult> => {
console.info('in getSignUrl');
try {
const response = await request(serverUrl, {
method: http.RequestMethod.POST,
header: {
'Content-Type': 'application/json'
},
extraData: {
fileName,
method: req.method,
headers: req.headers,
queries: req.queries,
additionalHeaders: req.additionalHeaders
},
expectDataType: http.HttpDataType.OBJECT
}, 200);
const result = response.result as ISignUrlResult;
console.info('success getSignUrl');
return result;
} catch (err) {
console.info('getSignUrl request error: ' + JSON.stringify(err));
throw err;
}
};
四、上传文件
获取在服务端生成的签名URL,然后使用PutObject的方法上传文件。
const putObject = async (fileUri: string): Promise<void> => {
console.info('in putObject');
const fileInfo = await fs.open(fileUri, fs.OpenMode.READ_ONLY);
const fileStat = await fs.stat(fileInfo.fd);
let signUrlResult: ISignUrlResult;
console.info('file name: ', fileInfo.name);
try {
// 获取PutObject的签名URL
signUrlResult = await getSignUrl(fileInfo.name, {
method: 'PUT',
headers: {
'Content-Length': fileStat.size
},
additionalHeaders: ['Content-Length']
});
} catch (e) {
await fs.close(fileInfo.fd);
throw e;
}
const data = new ArrayBuffer(fileStat.size);
await fs.read(fileInfo.fd, data);
await fs.close(fileInfo.fd);
try {
// 使用PutObject方法上传文件
await request(signUrlResult.url, {
method: http.RequestMethod.PUT,
header: {
'Content-Length': fileStat.size,
'Content-Type': signUrlResult.contentType
},
extraData: data
}, 200);
console.info('success putObject');
} catch (err) {
console.info('putObject request error: ' + JSON.stringify(err));
throw err;
}
};
结果验证
以上步骤部署完成后,您可以鸿蒙环境中实现上传文件到OSS的功能,效果示例如下:
点击Upload File按钮选择上传的文件。
在Bucket列表页面,选择您之前创建的用来存放用户上传文件的Bucket并打开。您可以在上传列表看到用户在鸿蒙端上传的文件。
清理资源
在本方案中,您创建了1台ECS实例、1个OSS Bucket、和1个RAM角色。测试完方案后,您可以参考以下规则处理对应产品的资源,避免继续产生费用或产生安全风险。
释放ECS实例
如果您不再需要这台实例,可以将其释放。释放后,实例停止计费,数据不可恢复。具体操作如下:
返回云服务器ECS控制台的实例列表页面,根据地域、实例ID找到目标ECS实例,单击操作列下的。
选择释放。
确认实例无误后,选择立即释放,单击下一步。
确认即将释放的关联资源,并了解相关数据风险后,单击确认,即可完成ECS实例的释放。
系统盘、分配的公网IP将随实例释放。
安全组、交换机和VPC不会随实例释放,但它们均为免费资源,您可根据实际的业务需要选择性删除。
弹性公网IP不会随实例释放,且不是免费资源,您可根据实际的业务需要选择性删除。
删除Bucket
登录OSS管理控制台。
单击Bucket 列表,然后单击目标Bucket名称。
删除Bucket的所有文件(Object)。
在左侧导航栏,单击删除Bucket,然后按照页面指引完成删除操作。
删除RAM角色
使用RAM管理员登录RAM控制台。
在左侧导航栏,选择身份管理 > 角色。
在角色页面,单击目标RAM角色操作列的删除角色。
在删除角色对话框,输入RAM角色名称,然后点击删除角色。
说明如果RAM角色被授予了权限策略,删除角色时,会同时解除授权。
常见问题
如何实现分片上传?
当您希望使用签名URL以分片上传的方式上传大文件到OSS时,您需要先初始化分片上传,然后为每一个分片生成一个对应的上传签名URL,并返回给客户端。客户端可以使用这些签名URL上传所有的分片信息,然后合并分片来达到通过签名URL实现分片上传的目的。具体代码实现可参考:
import { http } from '@kit.NetworkKit';
import fs from '@ohos.file.fs';
import { getSignUrl } from './upload';
import { request } from './request';
import { xmlToObj } from './xml';
type TPart = {
partNum: number;
etag: string;
};
type TTodoPart = {
partLength: number;
partNum: number;
}
/**
* InitiateMultipartUpload
* @param fileName 文件名
*/
const initiateMultipartUpload = async (fileName: string) => {
console.info('in initiateMultipartUpload');
// 获取InitiateMultipartUpload签名URL
const signUrlResult = await getSignUrl(fileName, {
method: 'POST',
queries: {
uploads: null
}
});
try {
// 通过InitiateMultipartUpload接口来通知OSS初始化一个Multipart Upload事件
const response = await request(signUrlResult.url, {
method: http.RequestMethod.POST,
expectDataType: http.HttpDataType.STRING
}, 200);
const result = response.result as string;
console.info('success initiateMultipartUpload');
const res = xmlToObj(result) as {
InitiateMultipartUploadResult: {
Bucket: string;
Key: string;
UploadId: string;
EncodingType?: string;
}
};
return res.InitiateMultipartUploadResult;
} catch (err) {
console.info('initiateMultipartUpload request error: ' + JSON.stringify(err));
throw err;
}
};
/**
* UploadPart
* @param uploadId 分片上传的uploadId
* @param partNum 分片上传的partNumber
* @param file 上传的文件
* @param length 分片大小
* @param offset 文件读取位置
*/
const uploadPart = async (uploadId: string, partNum: number, file: fs.File, length: number, offset: number = 0) => {
console.info('in uploadPart');
// 获取UploadPart签名URL
const signUrlResult = await getSignUrl(file.name, {
method: 'PUT',
headers: {
'Content-Length': length
},
queries: {
uploadId,
partNumber: partNum.toString()
},
additionalHeaders: ['Content-Length']
});
const data = new ArrayBuffer(length);
await fs.read(file.fd, data, {
length,
offset
});
try {
const response = await request(signUrlResult.url, {
method: http.RequestMethod.PUT,
header: {
'Content-Length': length,
'Content-Type': signUrlResult.contentType
},
extraData: data
}, 200);
console.info('success uploadPart');
return response.header['etag'] as string;
} catch (err) {
console.info('uploadPart request error: ' + JSON.stringify(err));
throw err;
}
};
/**
* CompleteMultipartUpload
* @param fileName 文件名
* @param uploadId 分片上传的uploadId
* @param completeAll 指定是否列举当前UploadId已上传的所有Part
* @param [parts] CompleteMultipartUpload所需的Part列表
*/
const completeMultipartUpload = async (fileName: string, uploadId: string, completeAll: boolean = false, parts?: TPart[]) => {
console.info('in completeMultipartUpload');
if (!completeAll && !parts) {
throw new Error('completeMultipartUpload needs to pass in parameter parts.');
}
const signUrlResult = await getSignUrl(fileName, {
method: 'POST',
headers: completeAll ? {
'x-oss-complete-all': 'yes'
} : {
'Content-Type': 'application/xml'
},
queries: {
uploadId
}
});
let xml: string;
if (!completeAll) {
const completeParts = parts.concat().sort((a, b) => a.partNum - b.partNum)
.filter((item, index, arr) => !index || item.partNum !== arr[index - 1].partNum);
xml = '<?xml version="1.0" encoding="UTF-8"?>\n<CompleteMultipartUpload>\n';
completeParts.forEach(item => {
xml += `<Part>\n<PartNumber>${item.partNum}</PartNumber>\n<ETag>${item.etag}</ETag>\n</Part>\n`
});
xml += '</CompleteMultipartUpload>';
}
try {
const result = await request(signUrlResult.url, {
method: http.RequestMethod.POST,
header: completeAll ? {
'x-oss-complete-all': 'yes'
} : {
'Content-Type': 'application/xml'
},
extraData: !completeAll ? xml : undefined
}, 200);
console.info('success completeMultipartUpload');
return result;
} catch (err) {
console.info('completeMultipartUpload request error: ' + JSON.stringify(err));
throw err;
}
};
/**
* 分片上传信息
*/
interface ICheckpoint {
/** 分片上传的uploadId */
uploadId: string;
/** 文件URI */
fileUri: string;
/** 分片大小 */
partSize: number;
/** 已经上传完成的分片 */
doneParts: TPart[];
}
/**
* 分片上传
*/
export class MultipartUpload {
/** 分片上传信息 */
private checkpoint: ICheckpoint;
/** 上传文件 */
private file: fs.File;
/** 文件详细属性信息 */
private fileStat: fs.Stat;
/** 取消上传标识 */
private cancelFlag = true;
/** 并发上传数 */
private parallel = 5;
/** 上传队列 */
private uploadQueue: TTodoPart[] = [];
/** 当前正在上传数 */
private uploadingCount = 0;
/** 上传失败分片信息 */
private uploadErrors: {
partNum: number;
uploadError: Error;
}[] = [];
/**
* 创建MultipartUpload实例
* @param [fileUri] 文件URI
* @param [checkpoint] 分片上传信息
*/
constructor(fileUri?: string, checkpoint?: ICheckpoint) {
if (checkpoint) {
this.checkpoint = checkpoint;
this.file = fs.openSync(checkpoint.fileUri, fs.OpenMode.READ_ONLY);
} else {
if (!fileUri) {
throw Error('MultipartUpload need fileUri or checkpoint.');
}
this.file = fs.openSync(fileUri, fs.OpenMode.READ_ONLY);
this.checkpoint = {
uploadId: '',
fileUri,
partSize: 2 ** 20,
doneParts: []
};
}
this.fileStat = fs.statSync(this.file.fd);
}
private async uploadPart(part: TTodoPart, resolve: () => void) {
this.uploadingCount++;
const {
partLength,
partNum
} = part;
try {
const result = await uploadPart(this.checkpoint.uploadId, partNum, this.file, partLength, (partNum - 1) * this.checkpoint.partSize);
this.checkpoint.doneParts.push({
partNum: partNum,
etag: result
});
this.uploadingCount--;
if(this.uploadErrors.length < 1) {
if (this.uploadQueue.length < 1 && this.uploadingCount < 1) {
resolve();
} else {
this.next(resolve);
}
}
} catch (e) {
this.uploadingCount--;
this.uploadErrors.push({
partNum: partNum,
uploadError: e
});
resolve();
}
}
private next(resolve: () => void) {
if (this.cancelFlag) {
resolve();
}
if (this.uploadQueue.length > 0 && this.uploadingCount < this.parallel && this.uploadErrors.length < 1) {
this.uploadPart(this.uploadQueue.shift(), resolve);
}
}
/**
* 执行分片上传
*/
async multipartUpload() {
this.cancelFlag = false;
this.uploadQueue = [];
this.uploadErrors = [];
if (this.checkpoint.uploadId === '') {
const initResult = await initiateMultipartUpload(this.file.name);
this.checkpoint.uploadId = initResult.UploadId;
}
const partsSum = Math.ceil(this.fileStat.size / this.checkpoint.partSize);
for (let i = 0; i < partsSum; i++) {
if (this.checkpoint.doneParts.findIndex(v => v.partNum === i + 1) === -1) {
this.uploadQueue.push({
partLength: i + 1 === partsSum ? this.fileStat.size % this.checkpoint.partSize : this.checkpoint.partSize,
partNum: i + 1
});
}
}
const tempCount = Math.min(this.parallel, this.uploadQueue.length);
await new Promise<void>((resolve) => {
for (let i = 0; i < tempCount; i++) {
this.next(resolve);
}
});
if (this.cancelFlag) {
throw new Error('MultipartUpload cancel');
}
if (this.uploadErrors.length) {
throw new Error('Upload failed parts: ' + this.uploadErrors.map(i => i.partNum).join(','));
}
return await completeMultipartUpload(this.file.name, this.checkpoint.uploadId, false, this.checkpoint.doneParts);
}
cancel() {
this.cancelFlag = true;
}
};
export {
initiateMultipartUpload,
uploadPart,
completeMultipartUpload
};