承继上篇博客对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();
}
}
