鸿蒙环境服务端签名直传

通过服务端签名实现鸿蒙应用直传OSS使用户可以通过鸿蒙端直接使用PutObject接口上传文件到OSS,此过程通过在服务端实现URL签名机制确保上传的安全性。

方案概览

使用鸿蒙环境实现文件上传的过程如下:

image

要实现鸿蒙应用直传OSS,只需3步:

  1. 配置OSS:在OSS控制台上创建一个Bucket,用于存储用户上传的文件。

  2. 配置服务端:在服务端创建一个实例,用于从STS服务获取一个临时访问凭证,然后使用临时访问凭证生成签名 URL用于授权用户在一定时间内进行文件上传。

  3. 配置鸿蒙客户端:在鸿蒙客户端,实现从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应用在浏览器环境中直接上传的文件。

  1. 登录OSS管理控制台

  2. 在左侧导航栏,单击Bucket 列表然后单击创建 Bucket

  3. 创建 Bucket面板,选择快捷创建,按如下说明配置各项参数。

    参数

    示例值

    Bucket名称

    web-direct-upload

    地域

    华东1(杭州)

  4. 点击完成创建

步骤二:配置服务端

创建一台ECS并绑定角色

操作一:创建ECS实例

请您进入自定义购买页面,并根据如下各模块的内容,创建或选择购买ECS实例所需的基础资源。

  1. 选择地域 & 付费类型

    1. 根据业务需求,选择合适的付费类型。本文选择按量付费模式,此模式操作相对灵活。

    2. 基于业务场景对时延的要求,选择地域。通常来说离ECS实例的物理距离越近,网络时延越低,访问速度越快。本文以选择华东1(杭州)为例。

      image

  1. 创建专有网络VPC & 交换机

    创建VPC时,请您选择和ECS相同的地域,并根据业务需求规划网段。本文以创建华东1(杭州)地域的VPC和交换机为例。创建完毕后返回ECS购买页,刷新并选择VPC及交换机。

    说明

    创建VPC时,可同时创建交换机。

    image

    image

    image

  1. 选择规格 & 镜像

    选择实例的规格及镜像,镜像为实例确定安装的操作系统及版本。本文选择的实例规格为ecs.e-c1m1.large,在满足测试需求的同时,价格较为实惠。镜像为公共镜像Alibaba Cloud Linux 3.2104 LTS 64

    image

  1. 选择存储

    ECS实例选择系统盘,并按需选择数据盘。本文实现简单Web系统搭建,只需要系统盘存储操作系统,无需数据盘。

    image

  1. 绑定公网IP

    本实例需要支持公网访问。为了简化操作,本文选择直接为实例分配公网IP。您也可以在创建实例后,为实例绑定弹性公网IP,具体操作,请参见EIP绑定至ECS实例

    说明
    • 若未绑定公网IP,将无法使用SSHRDP通过公网直接访问实例,也无法通过公网验证实例中Web服务的搭建。

    • 本文选择按使用流量的带宽计费模式。此模式只需为所消耗的公网流量付费。更多信息,请参见公网带宽计费

    image

  1. 创建安全组

    为实例创建安全组。安全组是一种虚拟网络防火墙,能够控制ECS实例的出入流量。创建时,需要设置放行以下指定端口,便于后续访问ECS实例。

    端口范围:SSH(22)、RDP(3389)、HTTP(80)、HTTPS(443)。

    说明
    • 端口范围处选中的是ECS实例上运行的应用需开放的端口。

    • 此处创建的安全组默认设置0.0.0.0/0作为源的规则。0.0.0.0/0表示允许全网段设备访问指定的端口,如果您知道请求端的IP地址,建议后续设置为具体的IP范围。具体操作,请参见修改安全组规则

    image

  1. 创建密钥对

    1. 密钥对可作为登录时证明个人身份的安全凭证,创建完成后,必须下载私钥,以供后续连接ECS实例时使用。创建完毕后返回ECS购买页,刷新并选择密钥对。

    2. root具有操作系统的最高权限,使用root作为登录名可能会导致安全风险,建议您选择ecs-user作为登录名。

      说明

      创建密钥对后,私钥会自动下载,请您关注浏览器的下载记录,保存.pem格式的私钥文件。

      image

  1. 创建并查看ECS实例

    创建或选择好ECS实例所需的基础资源后,勾选《云服务器ECS服务条款》《云服务器ECS退订说明》,单击确认下单。在提示成功的对话框中,单击管理控制台,即可在控制台查看到创建好的ECS实例。请您保存以下数据,以便在后续操作中使用。

    • 实例ID:便于在实例列表中查询到该实例。

    • 地域:便于在实例列表中查询到该实例。

    • 公网IP地址:便于在后续使用ECS实例时,做Web服务的部署结果验证。

    imageimage

操作二:连接ECS实例

  1. 云服务器ECS控制台实例列表页面,根据地域、实例ID找到创建好的ECS实例,单击操作列下的远程连接image

  2. 远程连接对话框中,单击通过Workbench远程连接对应的立即登录image

  3. 登录实例对话框中,选择认证方式SSH密钥认证,用户名为root,输入或上传创建密钥对时下载的私钥文件,单击确定,即可登录ECS实例。

    说明

    私钥文件在创建密钥对时自动下载到本地,请您关注浏览器的下载记录,查找.pem格式的私钥文件。

    image

  4. 显示如下页面后,即说明您已成功登录ECS实例。image

操作三:在访问控制中创建RAM角色

  1. 进入RAM访问控制的创建角色页面。

  2. 在创建角色页面勾选阿里云服务,单击下一步

    image

  3. 勾选普通服务角色,填写角色名称如oss-web-upload。并选择受信服务为云服务器后,单击完成,即可完成创建。

    image

  4. 单击复制,保存角色的ARN。

    1.png

操作四:在访问控制中创建上传文件的权限策略

  1. 权限策略页面,单击创建权限策略

  2. 创建权限策略页面,单击脚本编辑,将以下脚本中的<Bucket名称>替换为准备工作中创建的Bucket名称web-direct-upload

    {
      "Version": "1",
      "Statement": [
        {
          "Effect": "Allow",
          "Action": "oss:PutObject",
          "Resource": "acs:oss:*:*:<Bucket名称>/*"
        }
      ]
    }
  3. 然后单击确定,填写策略名称。

操作五:在访问控制为RAM角色授予权限

  1. 角色页面,找到目标RAM角色,然后单击RAM角色右侧的新增授权

  2. 新增授权页面下的自定义策略页签,选择已创建的自定义权限策略。

  3. 单击确定

操作六:为ECS绑定RAM角色

  1. 进入云服务器ECS实例页面,在页面上方选择ECS实例所处地域,然后在实例页面单击目标实例右侧image按钮,单击授予/收回RAM角色

    image

  2. 授予/收回RAM角色弹出框选择目标RAM角色,完成ECS绑定RAM角色。

    image

说明

生成的临时身份凭证用于下一步生成签名URL,如果您已经拥有临时身份凭证,可以直接跳转到下一步三、服务端生成URL签名

二、服务端生成临时访问凭证

操作一、ECS服务端配置依赖

请执行以下命令安装获取临时访问凭证所需要的依赖项。

Python

  1. 安装Python3

  2. 执行以下命令,安装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的功能,效果示例如下:

  1. 点击Upload File按钮选择上传的文件。

    image

  2. Bucket列表页面,选择您之前创建的用来存放用户上传文件的Bucket并打开。您可以在上传列表看到用户在鸿蒙端上传的文件。

    image

清理资源

在本方案中,您创建了1ECS实例、1OSS Bucket、和1RAM角色。测试完方案后,您可以参考以下规则处理对应产品的资源,避免继续产生费用或产生安全风险。

释放ECS实例

如果您不再需要这台实例,可以将其释放。释放后,实例停止计费,数据不可恢复。具体操作如下:

  1. 返回云服务器ECS控制台实例列表页面,根据地域、实例ID找到目标ECS实例,单击操作列下的image

  2. 选择释放image

  3. 确认实例无误后,选择立即释放,单击下一步

  4. 确认即将释放的关联资源,并了解相关数据风险后,单击确认,即可完成ECS实例的释放。

说明
  • 系统盘、分配的公网IP将随实例释放。

  • 安全组、交换机和VPC不会随实例释放,但它们均为免费资源,您可根据实际的业务需要选择性删除。

  • 弹性公网IP不会随实例释放,且不是免费资源,您可根据实际的业务需要选择性删除。

删除Bucket

  1. 登录OSS管理控制台

  2. 单击Bucket 列表,然后单击目标Bucket名称。

  3. 删除Bucket的所有文件(Object)。

  4. 在左侧导航栏,单击删除Bucket,然后按照页面指引完成删除操作。

删除RAM角色

  1. 使用RAM管理员登录RAM控制台

  2. 在左侧导航栏,选择身份管理 > 角色

  3. 角色页面,单击目标RAM角色操作列的删除角色

  4. 删除角色对话框,输入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
};