用 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();
  }
}
相关推荐
johnny2332 小时前
MinIO替代汇总(二):GlusterFS、Garage、Free FS
minio
MayBaymax6 小时前
MQ 基础概念与架构
java·中间件·架构·java-rocketmq
灯澜忆梦13 小时前
【minio】#5 | MinIO 分布式部署 + HTTPS 部署
分布式·网络协议·https·对象存储·minio
工作10年+,存储芯片行业13 小时前
存储芯片产业全景:从产品矩阵到技术优势与质量体系
ai·nvme·芯片·数据中心·存储·pcie·半导体
工作10年+,存储芯片行业1 天前
长鑫存储深度分析:国产 DRAM 的崛起、挑战与未来
人工智能·ssd·芯片·存储·pcie·dram·ddr
工作10年+,存储芯片行业1 天前
武汉新芯发展历程与产品体系全览
ssd·集成电路·芯片·存储·半导体·flash·nor
工作10年+,存储芯片行业2 天前
长存科技:发展历程与产品体系全览
科技·ssd·存储·pcie·xtacking
灯澜忆梦2 天前
【minio】#1 | 简介与安装
docker·对象存储·minio
染指11102 天前
134.Agent-多Agent框架-LangChain多智能体
人工智能·中间件·langchain·agents
灯澜忆梦2 天前
【minio】#2 | Web 控制台操作
对象存储·minio