用 Dart 通过 S3 API 操作 MinIO:文件上传与下载 Demo

承继上篇博客对minIO的搭建完成,使用dart实现对其的基础操作,主要包括:

  • 对minIO上文件的上传和下载;
  • 通过预签名url,使用curl上传下载文件。

注:

1、预签名 URL(Presigned URL) 是一个带签名的临时链接,拿到它的人可以在规定时间内、用规定的方式访问某个对象,而不需要知道对方的账号密钥。

2、S3 Client‌(中文常译为 ‌S3 客户端‌) 是指用于与兼容 ‌Amazon S3 API‌ 的对象存储服务进行交互的软件工具、库或应用程序。简单来说,它就像是一个"遥控器"或"驱动程序",让代码、脚本或命令行工具能够连接到支持 S3 协议的存储系统(如 AWS S3、MinIO、Ceph、阿里云 OSS 等),从而执行上传、下载、删除文件以及管理存储桶等操作。

minIOn我配置如图所示,上传了,新建了一个jlmtest的bucket。Demo程序$ dart run xxx.dart运行完成后,可到界面上刷新确认。

Demo 1:单文件上传

依赖插件:aws_s3_api: ^2.0.0

dart 复制代码
import 'dart:io';
import 'package:aws_s3_api/s3-2006-03-01.dart';

void main() async {
  // S3 client setup
  final s3 = S3(
    region: 'us-east-1',
    credentials: AwsClientCredentials(
      accessKey: 'jlmtest',
      secretKey: 'jlmtestpwd',
    ),
    // For a local MinIO server, also set:
    endpointUrl: 'http://localhost:9000',
  );
  
  final file = File('/home/jlm/Applications/Investigation/minioTest.html');// 待上传的本地文件名
  final bucketName = 'jlmtest';
  final key = 'minioTest.html';// 待上传的minIO上的文件名
  
  try {
    // 将本地文件上传到minIO
    await s3.putObject(
      bucket: bucketName,
      key: key,
      body: await file.readAsBytes(), // 将本地文件内容读取为字节流
    );

    print('File uploaded successfully to $bucketName/$key');
  } catch (e) {
    print('Error uploading file: $e');
  } finally {
    s3.close();
  }
}

Demo 2:单文件下载

依赖:aws_s3_api: ^2.0.0

dart 复制代码
import 'dart:io';
import 'package:aws_s3_api/s3-2006-03-01.dart';

void main() async {
  final s3 = S3(
    region: 'us-east-1',
    credentials: AwsClientCredentials(
      accessKey: 'jlmtest',
      secretKey: 'jlmtestpwd',
    ),
    endpointUrl: 'http://localhost:9000',
  );

  final bucketName = 'jlmtest';
  final key = 'index.html';// 待下载的 minIO 上的文件名
  final output = File('downloaded_index.html');// 下载到本地的文件名
  try {
    // 拿到 minIO 上的文件
    final response = await s3.getObject(
      bucket: bucketName,
      key: key,
    );
    final body = response.body;
    if (body == null) {
      throw StateError('Downloaded object has an empty body: $bucketName/$key');
    }

    // 将文件内容写入本地文件
    await output.writeAsBytes(body);
    print('File downloaded successfully to ${output.path}');
  } catch (e) {
    print('Error downloading file: $e');
  } finally {
    s3.close();
  }
}

Demo 3:预签名url单文件下载

依赖:

aws_common: ^0.7.15

aws_s3_api: ^2.0.0

aws_signature_v4: ^0.6.13

dart 复制代码
import 'package:aws_common/aws_common.dart';
import 'package:aws_signature_v4/aws_signature_v4.dart';

/// Generates an S3 presigned GET URL for MinIO.
///
/// Signing happens locally --- MinIO is not contacted until someone opens the URL.
void main() async {
  const endpointHost = 'localhost:9000';
  const bucketName = 'jlmtest';
  const key = 'minioTest.html';

  const credentials = AWSCredentials('jlmtest', 'jlmtestpwd');
  const signer = AWSSigV4Signer(
    credentialsProvider: AWSCredentialsProvider(credentials),
  );

  final signedUrl = await signer.presign(
    // MinIO uses path-style URLs: http://host/bucket/key
    AWSHttpRequest.get(
      Uri.http(endpointHost, '/$bucketName/$key'),
      headers: {
        AWSHeaders.host: endpointHost
      },
    ),
    credentialScope: AWSCredentialScope(
      region: 'us-east-1',
      service: AWSService.s3
    ),
    serviceConfiguration: S3ServiceConfiguration(),
    expiresIn: const Duration(minutes: 10), // 有效期 10 分钟
  );

  print('Presigned GET URL (valid 10 minutes):');
  print(signedUrl);
}

测试命令:

bash 复制代码
1、$ dart run bin/minio_presign_download_demo.dart
2、$ curl -o mintestDownload.html 'http://localhost:9000/jlmtest/minioTest.html?X-Amz-Date=20260915T105656Z&X-Amz-SignedHeaders=host&X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=jlmtest%2F20260915%2Fus-east-1%2Fs3%2Faws4_request&X-Amz-Expires=600&X-Amz-Signature=f99ac7935f291e03e1779a2be94a21741be9d1e47d490163105084620879a1f5'

执行curl后,minIO上对应的文件被成功下载,效果如图所示:

Demo 4:预签名url单文件上传(区分 整文件 及 分片上传)

依赖:

aws_common: ^0.7.15

aws_s3_api: ^2.0.0

aws_signature_v4: ^0.6.13

dart 复制代码
import 'dart:convert';
import 'dart:io';
import 'dart:math';
import 'dart:typed_data';

import 'package:aws_common/aws_common.dart';
import 'package:aws_s3_api/s3-2006-03-01.dart';
import 'package:aws_signature_v4/aws_signature_v4.dart';

void main() async {
  const region = 'us-east-1';
  const endpointHost = 'localhost:9000';
  const bucketName = 'jlmtest';
  const expiresIn = Duration(minutes: 10);
  const singlePutMaxBytes = 1 * 1024 * 1024;
  const partSizeBytes = 5 * 1024 * 1024;

  String smallFilePathForTest = '/home/jlm/Applications/Investigation/minioTest.html'; // <= 1 MB
  String largeFilePathForTest = '/home/jlm/Applications/ARBackend/bin/ClientTestMethod_noGetVCRPlay'; // > 1 MB
  String keyForTest = 'uploadTest.tmp';

  // final file = File(smallFilePathForTest);
  final file = File(largeFilePathForTest);
  if (!file.existsSync()) {
    throw StateError('File not found: ${file.path}');
  }
  final key = keyForTest;
  final fileSize = await file.length();

  const credentials = AWSCredentials('jlmtest', 'jlmtestpwd');
  const signer = AWSSigV4Signer(
    credentialsProvider: AWSCredentialsProvider(credentials),
  );
  final scope = AWSCredentialScope(region: region, service: AWSService.s3);
  final serviceConfiguration = S3ServiceConfiguration();

  final s3 = S3(
    region: region,
    credentials: AwsClientCredentials(
      accessKey: credentials.accessKeyId,
      secretKey: credentials.secretAccessKey,
    ),
    endpointUrl: 'http://$endpointHost',
  );

  print('Local file: ${file.path} ($fileSize bytes)');
  print('Destination: $bucketName/$key');

  try {
    // 如果文件大小 <= 1 MB,则使用单次 PUT 上传
    if (fileSize <= singlePutMaxBytes) {
      await _uploadWholeFile(
        signer: signer, // 签名器
        scope: scope, // 凭证范围
        serviceConfiguration: serviceConfiguration, // 服务配置
        endpointHost: endpointHost, // 端点主机
        bucketName: bucketName, // 桶名
        key: key, // 对象键
        file: file, // 文件
        expiresIn: expiresIn, // 有效期
      );
    } else {
      await _uploadMultipart(
        s3: s3,
        signer: signer,
        scope: scope,
        serviceConfiguration: serviceConfiguration,
        endpointHost: endpointHost,
        bucketName: bucketName,
        key: key,
        file: file,
        fileSize: fileSize,
        partSizeBytes: partSizeBytes,
        expiresIn: expiresIn,
      );
    }
    print('Upload finished: $bucketName/$key');
  } finally {
    s3.close();
  }
}

Future<void> _uploadWholeFile({
  required AWSSigV4Signer signer,
  required AWSCredentialScope scope,
  required S3ServiceConfiguration serviceConfiguration,
  required String endpointHost,
  required String bucketName,
  required String key,
  required File file,
  required Duration expiresIn,
}) async {
  print('Mode: single PUT (file <= 1 MiB)');
  final signedUrl = await _presignPut(
    signer: signer,
    scope: scope,
    serviceConfiguration: serviceConfiguration,
    endpointHost: endpointHost,
    bucketName: bucketName,
    key: key,
    expiresIn: expiresIn,
  );
  print('Presigned PUT URL (valid ${expiresIn.inMinutes} minutes):');
  print(signedUrl);

  final result = await _putBytes(signedUrl, await file.readAsBytes());
  print('PUT status: ${result.statusCode} ETag: ${result.eTag}');
}

Future<void> _uploadMultipart({
  required S3 s3,
  required AWSSigV4Signer signer,
  required AWSCredentialScope scope,
  required S3ServiceConfiguration serviceConfiguration,
  required String endpointHost,
  required String bucketName,
  required String key,
  required File file,
  required int fileSize,
  required int partSizeBytes,
  required Duration expiresIn,
}) async {
  final partCount = (fileSize + partSizeBytes - 1) ~/ partSizeBytes;
  print(
    'Mode: multipart ($partCount part(s), part size $partSizeBytes bytes)',
  );

  // 开一个multipart上传会话,返回uploadId,POST?uploads
  final created = await s3.createMultipartUpload(bucket: bucketName, key: key);
  final uploadId = created.uploadId;
  if (uploadId == null || uploadId.isEmpty) {
    throw StateError('createMultipartUpload did not return uploadId');
  }
  print('uploadId: $uploadId');

  final completedParts = <CompletedPart>[];
  final raf = await file.open();
  try {
    var offset = 0;
    var partNumber = 1;
    while (offset < fileSize) {
      final length = min(partSizeBytes, fileSize - offset);
      final bytes = await raf.read(length);
      final signedUrl = await _presignPut(
        signer: signer,
        scope: scope,
        serviceConfiguration: serviceConfiguration,
        endpointHost: endpointHost,
        bucketName: bucketName,
        key: key,
        expiresIn: expiresIn,
        queryParameters: {
          'partNumber': '$partNumber',
          'uploadId': uploadId,
        },
      );
      print(
        'Presigned PUT URL for part $partNumber '
        '($length bytes, valid ${expiresIn.inMinutes} minutes):',
      );
      print(signedUrl);

      // 每片独立上传,响应里带ETag, PUT?partNumber=&uploadId=xxx
      final result = await _putBytes(signedUrl, bytes);
      completedParts.add(
        CompletedPart(eTag: result.eTag, partNumber: partNumber),
      );
      print('Part $partNumber status: ${result.statusCode} ETag: ${result.eTag}');

      offset += length;
      partNumber++;
    }

    // 收尾,提交各个片的ETag,服务器才回去拼成最终对象 bucketName/key,POST?uploadId=+各片清单
    await s3.completeMultipartUpload(
      bucket: bucketName,
      key: key,
      uploadId: uploadId,
      multipartUpload: CompletedMultipartUpload(parts: completedParts),
    );
  } catch (e) {
    // 失败,丢弃未完成的分片上传,避免占用存储空间,DELETE?uploadId=xx
    await s3.abortMultipartUpload(
      bucket: bucketName,
      key: key,
      uploadId: uploadId,
    );
    print('Multipart upload aborted: $e');
    rethrow;
  } finally {
    await raf.close();
  }
}

Future<Uri> _presignPut({
  required AWSSigV4Signer signer,
  required AWSCredentialScope scope,
  required S3ServiceConfiguration serviceConfiguration,
  required String endpointHost,
  required String bucketName,
  required String key,
  required Duration expiresIn,
  Map<String, String>? queryParameters,
}) {
  final request = AWSHttpRequest.put(
    Uri.http(endpointHost, '/$bucketName/$key', queryParameters),
    headers: {AWSHeaders.host: endpointHost},
  );
  return signer.presign(
    request,
    credentialScope: scope,
    serviceConfiguration: serviceConfiguration,
    expiresIn: expiresIn,
  );
}

Future<({int statusCode, String eTag})> _putBytes(
  Uri url,
  Uint8List bytes,
) async {
  final client = HttpClient();
  try {
    final request = await client.putUrl(url);
    request.headers.removeAll(HttpHeaders.contentTypeHeader);
    request.contentLength = bytes.length;
    request.add(bytes);
    final response = await request.close();
    final body = await utf8.decodeStream(response);
    if (response.statusCode != 200) {
      throw StateError('PUT failed (${response.statusCode}): $body');
    }
    return (
      statusCode: response.statusCode,
      eTag: response.headers.value(HttpHeaders.etagHeader) ?? '',
    );
  } finally {
    client.close();
  }
}
相关推荐
爱和冰阔落1 天前
【Linux】条件变量为什么必须配合互斥锁:从 pthread_cond_wait 到阻塞队列
linux·运维·c++·缓存·中间件·安卓
weixin199701080162 天前
《跨境二手ERP的3种对接模式:自研API / SaaS中间件 / 平台认证服务商,怎么选?》(附Python源码)
开发语言·python·中间件
hyunbar5 天前
LangChain 实战:什么是MiddleWare(中间件)?
中间件·langchain
NineData5 天前
NineData智能数据管理平台新功能发布|2026年8月
数据库·人工智能·oracle·中间件·agent·数据库开发·ninedata
老猿讲编程5 天前
【Eclipse OpenSOVD学习之十一】网关二进制与部署
学习·中间件·rust·eclipse·汽车
Terra.K5 天前
JAVA职业探索和学习----中间件开发目标
java·学习·中间件
总是幸福的老豌豆6 天前
EmbCom · 具身智能传输中间件
中间件
2401_868534786 天前
第十章 网络规划设计
安全·中间件
「、皓子~8 天前
海狸IM 2.1 私有化部署从零到一:中间件、微服务与 Nginx 全流程
nginx·flutter·微服务·中间件·开源软件·im·海狸im