Flutter实现文件上传华为对象存储(OBS)

3,796 阅读7分钟

本文主要讲述在 Flutter 项目中如何实现将文件上传到华为 OBS(对象存储)中,并封装为三方库方便灵活使用。

背景介绍

在大多项目中都会存在文件上传的需求,之前的实现都是调用后台的文件上传接口将文件上传到服务器上,但是这样会存在一个问题,因为文件上传会占用带宽导致在文件上传中调用其他接口的时候就会存在访问慢的情况,解决方案当然是升级带宽或者单独使用一台服务器作为文件服务,而且要带宽足够大不然上传下载的时候会很慢,但是这样两种方案成本都比较高。随着云计算的到来,各大云服务商都提供了对象存储的服务,费用便宜、带宽高、不影响业务系统而且提供了很多附加功能,比如图片处理、图片鉴黄等功能。

因目前在做的项目甲方爸爸明确要求云服务要使用华为云,所以对象存储服务也必须使用华为云的 OBS 服务,而为了节约人力成本移动端使用的是 Flutter 跨平台开发,所以就有了本篇文章标题的需求,需要在 Flutter 中实现将文件上传到华为云 OBS 中,而华为云 OBS 并没有提供 Flutter SDK,所以就需要自己实现,首先看一下实现以后的代码使用效果。

使用

目前只封装了两个简单的功能:上传对象、上传文件。

首先在项目的 pubspec.yaml 里添加依赖,如下:

  flutter_hw_obs:
    git:
      url: https://github.com/loongwind/flutter_hw_obs.git
      ref: 0.0.3

然后在使用的地方引入obs_client包:

import 'package:flutter_hw_obs/obs_client.dart';

初始化

调用 OBSClient.init 进行初始化。

OBSClient.init("${AccessKey}", "${SecretAccessKey}", "${AccessDomain}", "${BucketName}");
​

参数说明:

  • AccessKey: 用于标识华为用户,在华为云控制台创建子账号获取
  • SecretAccessKey: 用于验证用户的密钥,在华为云控制台创建子账号获取
  • AccessDomain: 访问域名,创建 OBS 桶后会自动分配访问域名,如xxx.obs.cn-southwest-2.myhuaweicloud.com
  • BucketName: 桶名称,创建 OBS 桶时的名称

在使用其他 api 之前必须先进行初始化。

上传对象

使用 OBSClient.putObject 上传对象。

OBSResponse response = await OBSClient.putObject("${ObjectName}", data, xObsAcl="$xObsAcl");
​
OBSResponse response = await OBSClient.putObject("test/hello.txt", utf8.encode("Hello OBS"));

参数说明:

  • ObjectName:对象名称,即存储到 OBS 上的文件名称,带路径,如:test/hello.txt
  • data: 上传对象数据,类型是 List<int> 的二进制数据
  • xObsAcl: 上传对象的权限控制控制策略,可选值如下表所示,默认为public-read 即公共读
预定义的权限控制策略描述
private桶或对象的所有者拥有完全控制的权限,其他任何人都没有访问权限
public-read设在桶上,所有人可以获取该桶内对象列表、桶内多段任务、桶的元数据、桶的多版本。设在对象上,所有人可以获取该对象内容和元数据。
public-read-write设在桶上,所有人可以获取该桶内对象列表、桶内多段任务、桶的元数据、桶的多版本、上传对象删除对象、初始化段任务、上传段、合并段、拷贝段、取消多段上传任务。设在对象上,所有人可以获取该对象内容和元数据。
public-read-delivered设在桶上,所有人可以获取该桶内对象列表、桶内多段任务、桶的元数据、桶的多版本,可以获取该桶内对象的内容和元数据。不能应用在对象上。
public-read-write-delivered设在桶上,所有人可以获取该桶内对象列表、桶内多段任务、桶的元数据、桶的多版本、上传对象删除对象、初始化段任务、上传段、合并段、拷贝段、取消多段上传任务,可以获取该桶内对象的内容和元数据。不能应用在对象上。
bucket-owner-full-control设在对象上,桶或对象的所有者拥有完全控制的权限,其他任何人都没有访问权限。

返回结果是一个 OBSResponse 对象,代码如下:

class OBSResponse{
  String? objectName;
  String? fileName;
  String? url;
  int? size;
  String? ext;
  String? md5;
}

字段说明:

objectName: 对象名称,即上传到 OBS 的路径

fileName: 文件名称

url: OBS 的访问路径

size: 对象大小

ext: 文件后缀

md5: 对象 MD5 值

上传文件

使用OBSClient.putFile 可以进行文件上传,代码如下:

OBSResponse response = await OBSClient.putFile("test/test.png", File("/sdcard/test.png"), xObsAcl="public-read");

该方法与 OBSClient.putObject 很像,第一、第三个参数都一样,只有第二个参数不一样,这里第二个参数是一个 File 对象。返回结果同样也是 OBSResponse 对象。

代码实现

华为 OBS 虽然没提供 Flutter 的 SDK,但是却提供了 Android 和 iOS 的 SDK,所以最开始想到的是写一个 Flutter 的插件分别集成 OBS 的 Android SDK 和 iOS SDK,也确实这么做了 Android SDK 很轻松的就集成完成了,但是集成 iOS SDK 的时候却遇到各种错误,最后无奈放弃,当然也因为本人之前一直从事 Android 开发 iOS 开发能力不足导致。最后看了一下 OBS 的文档,有提供 API 的方式,而项目中的需求其实很简单就是上传文件,于是就用 Dart 结合 dio 实现了一个纯 Dart 的库。

创建 OBSResponse

首先创建一个 OBSResponse 实体类,用于上传 OBS 后的返回结果,代码如下:

class OBSResponse{
  String? objectName;
  String? fileName;
  String? url;
  int? size;
  String? ext;
  String? md5;
}

具体字段说明在上面使用介绍里已经说明了,这里就不过多介绍了。

创建 OBSClient

核心代码都在 OBSClient 里。首先定义 init 初始化方法,因为使用 OBS 的 API 需要一些必须的认证参数,如下:

class OBSClient {
​
  static String? ak;
  static String? sk;
  static String? bucketName;
  static String? domain;
​
  static void init(String ak, String sk, String domain, String bucketName){
    OBSClient.ak  = ak;
    OBSClient.sk = sk;
    OBSClient.domain = domain;
    OBSClient.bucketName = bucketName;
  }
}

然后定义初始化 dio 的方法,因为实现 api 请求使用的是 dio,如下:

  static Dio _getDio() {
    var dio = Dio();
    dio.interceptors.add(PrettyDioLogger(
        requestHeader: true, requestBody: true, responseHeader: true));
    return dio;
  }

这里很简单,就是初始化一个 Dio 对象,然后添加日志拦截器用于输出日志。

创建一个公共的 put 方法,因为 OBS 上传对象是一个统一的 api ,所以这里也封装一个统一的上传对象方法,如下:

static Future<OBSResponse?> put(String objectName, data , String md5, int size, {String xObsAcl = "public-read"}) async{
    if(objectName.startsWith("/")){
      objectName = objectName.substring(1);
    }
    String url = "$domain/$objectName";
​
    var contentMD5 = md5;
    var date = HttpDate.format(DateTime.now());
    var contentType = "application/octet-stream";
​
    Map<String, String> headers = {};
    headers["Content-MD5"] = contentMD5;
    headers["Date"] = date;
    headers["x-obs-acl"] = xObsAcl;
    headers["Authorization"] = _sign("PUT", contentMD5, contentType, date, "x-obs-acl:$xObsAcl", "/$bucketName/$objectName");
​
    Options options = Options(headers: headers, contentType: contentType);
​
    Dio dio = _getDio();
​
    await dio.put(url, data: data, options: options);
    OBSResponse obsResponse = OBSResponse();
    obsResponse.md5 = contentMD5;
    obsResponse.objectName = objectName;
    obsResponse.url = url;
    obsResponse.fileName = path.basename(objectName);
    obsResponse.ext = path.extension(objectName);
    obsResponse.size = size;
    return obsResponse;
  }

该方法参数有 5 个, objectName 是存储到 OBS 的文件全路径,data 是上传对象的数据,md5 是 data 的 md5 值,size 是 data 的大小,xObsAcl 是权限控制策略。其中 data 是一个动态类型,可以传入二进制数据、文件、字符串等,对应的获取 md5 和 size 的方法都不一样,所以这里提取成了参数。

在方法实现里首先判断了 objectName 是否以 / 开始,因为 OBS 的路径不支持 / 开始,所以这里做了处理,如果是 / 开始则移除 /

根据访问域名 domainobjectName 组装成 OBS 的访问 url。

接下来组装请求的 Header,Content-MD5 即为上传对象的 MD5 值,Date 为当前时间,x-obs-acl 就是传入的权限访问策略,Authorization 是身份认证,需要对请求进行签名,所以这里封装了一个 _sign 签名方法,实现如下:

  static String _sign(String httpMethod, String contentMd5, String contentType,
      String date, String acl, String res) {
    if (ak == null || sk == null) {
      throw "ak or sk is null";
    }
    String signContent =
        "$httpMethod\n$contentMd5\n$contentType\n$date\n$acl\n$res";
​
    return "OBS $ak:${signContent.toHmacSha1Base64(sk!)}";
  }

签名的算法是先将请求方法(PUT)、md5(对象 md5 值)、Content-Type(内容类型 application/octet-stream)、date(当前时间)、acl(权限策略)、res(桶名称+objectName)组装成一个字符串,然后对这个字符串进行 Hmac 编码再转 Base64,再在签名的内容前面拼上OBS 字符串和 AccessKey 值。toHmacSha1Base64 方法是自定义的字符串扩展方法,实现如下:

  String toHmacSha1Base64(String sk){
    var hmacSha1 = Hmac(sha1, utf8.encode(sk));
    return base64.encode(hmacSha1.convert(utf8.encode(this)).bytes);
  }

请求头封装好后调用 dio 的 put 方法进行上传,上传成功后组装 OBSResponse 进行返回。

这样通用的对象上传方法就完成了,接下看看 putObjectputFile 的实现:

  static Future<OBSResponse?> putObject(String objectName, List<int> data,{String xObsAcl = "public-read"}) async{
    String contentMD5 = data.toMD5Base64();
    int size = data.length;
    var stream = Stream.fromIterable(data.map((e) => [e]));
    OBSResponse? obsResponse = await put(objectName, stream, contentMD5, size, xObsAcl: xObsAcl);
    return obsResponse;
  }
​
  static Future<OBSResponse?> putFile(String objectName, File file,{String xObsAcl = "public-read"}) async{
    var contentMD5 = await getFileMd5Base64(file);
    var stream = file.openRead();
    OBSResponse? obsResponse = await put(objectName, stream, contentMD5, await file.length() xObsAcl: xObsAcl);
    return obsResponse;
  }

都是调用的上面封装的 put 方法,只是获取 md5 的方法、获取 size 的方法以及 data 不一样。

这里分别对 List<int> 和文件的获取 md5 进行了封装,如下:

List:

extension ListIntExt on List<int>{
  List<int> toMD5Bytes(){
    return md5.convert(this).bytes;
  }
​
  String toMD5(){
    return toMD5Bytes().toString();
  }
​
  String toMD5Base64(){
    return base64.encode(toMD5Bytes());
  }
}

文件

Future<List<int>> getFileMd5Bytes(File file) async{
  var digest = await md5.bind(file.openRead()).first;
  return digest.bytes;
}
​
Future<String> getFileMd5Base64(File file) async{
  var md5bytes = await getFileMd5Bytes(file);
  return base64.encode(md5bytes);
}

最后 List<int> 和文件转换为 Stream 的方法也不一样,List<int> 是通过 Stream.fromIterable(data.map((e) => [e])); 转换,而文件是通过 file.openRead() 获取。

OK,大功告成,使用 Dart 通过 OBS api 实现对象上传的封装就完成了,虽然功能还不完全,但是已经能满足最基础的使用了,希望对你有所帮助,后续将对这个库进行持续完善以支持更多的功能。

源码地址:flutter_hw_obs

\