用 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();
  }
}
相关推荐
黑猫学长呀16 小时前
存储芯片千问千答第11问:对于磨损类用例,waf越大的模型磨损的更快,还是waf更小的模型磨损的更快?
gc·存储·ftl·ufs·nand·存储芯片·nandflash
ctlover16 小时前
langchain 中间件
中间件·langchain·1024程序员节
李兆龙的博客21 小时前
从一到无穷大 #105:OpenAI Habitat II——多租户演进、全球读取优化与 CDC 平台
云计算·存储
李兆龙的博客21 小时前
从一到无穷大 #101:SSD 的工作原理——从 NAND 到数据库读写
数据库·ssd·存储
宝贝儿好3 天前
【LLM】第十一章:Agent中间件2:ModelCallLimitMiddleware
中间件
梦影_5 天前
Langchain简单快速上手教程(九)—— 核心组件之少样本提示
人工智能·python·自然语言处理·中间件·langchain
梦影_6 天前
MySQL进阶教程(一)—— 储存过程与触发器(上)
开发语言·数据库·后端·sql·mysql·中间件
eleven_4u7 天前
RocketMQ相关问题
java·运维·中间件
闻缺陷则喜何志丹7 天前
vc入门宝典(九)
数据结构·list·mfc·存储·null·class
jonyleek7 天前
JVS数字底座私有化部署实践:Spring Cloud + K8S 实现文档平台开箱即用
elasticsearch·kubernetes·私有化部署·springcloud·minio·jvs企业文档·文档治理