目录

59..NET8-实战-孢子记账-从单体到微服务-转向微服务-新增功能-MinIO对象存储服务

59.【.NET8 实战–孢子记账–从单体到微服务–转向微服务】–新增功能–MinIO对象存储服务

在孢子记账中我们需要存储用户的头像、账单的图片等文件,这些文件的存储我们可以使用MinIO对象存储服务,
MinIO提供了高性能、可扩展的对象存储解决方案,能够帮助我们轻松管理这些文件资源。通过MinIO,我们可以将用户上传的图片文件安全地存储在云端,并且可以随时通过HTTP访问这些资源。

一、关于MinIO

1. 什么是MinIO

MinIO是一个功能强大的开源对象存储服务器,基于Apache License v2.0开源协议发布。作为一个现代化的存储解决方案,它为用户提供了企业级的高可用性、无限的水平扩展能力以及卓越的性能表现。MinIO采用Go语言开发,具有轻量级、易部署的特点,同时提供与Amazon S3兼容的API接口,使得它能够无缝对接现有的云存储生态系统。

在架构设计上,MinIO采用分布式架构,支持多节点部署和数据复制,确保数据的可靠性和容错能力。它使用纠删码技术来保护数据,即使在多个节点故障的情况下也能保证数据的完整性。MinIO的性能表现尤为出色,单个节点就能达到数GB/s的读写速度,并且随着节点的增加,性能可以线性提升。

MinIO的应用场景十分广泛,特别适合现代云原生应用的开发需求。在大数据分析领域,它可以作为数据湖的存储后端,支持Hadoop、Spark等大数据框架的直接访问。在机器学习和AI训练场景中,MinIO能够高效处理大量的训练数据集。对于需要处理图片、视频等多媒体文件的应用,MinIO提供了快速的上传下载能力和便捷的文件管理功能。此外,它还可以作为备份存储、日志存储等基础设施的重要组成部分。

2. 安装部署MinIO

MinIO的安装部署非常简单,我们可以在官方网站下载对应操作系统的安装包,然后按照安装指南进行安装。MinIO支持在Linux、Windows和macOS等操作系统上运行,同时也提供了Docker镜像,方便在容器化环境中部署。我们以Docker为例,来看一下如何部署。

首先,我们需要拉取MinIO的Docker镜像:

docker pull minio/minio

这个命令会拉取最新的MinIO镜像文件,之后使用docker images命令查看MinIO的镜像是否已经已拉取到本地:

docker images

我们可以看到MinIO的镜像文件已经被成功拉取到本地。
https://i-blog.csdnimg.cn/direct/bd5f2b1ce11b4eba98044e5c39205fdc.png

mkdir -p $HOME/minio/data
mkdir -p $HOME/minio/config

执行这个命令后,将在当前用户主目录下创建路径 minio/dataminio/config,这两个路径分别用来存储MinIO的文件数据和配置文件,后续用来存储MinIO的文件数据,并且这个两个目录将会挂在到MinIO容器中。

接下来,我们要创建MinIO的容器:

docker run -d --name minio \
  -p 9000:9000 -p 9001:9001 \
  -e "MINIO_ACCESS_KEY=minioadmin" \
  -e "MINIO_SECRET_KEY=minioadmin" \
  -v $HOME/minio/data:/data \
  -v $HOME/minio/config:/root/.minio \
  minio/minio server /data --console-address ":9001"

这个命令会创建一个名为minio的容器,将MinIO的文件数据存储在$HOME/minio/data目录下,将MinIO的配置文件存储在$HOME/minio/config目录下,并且创建了一个名为minioadmin的用户,用户的访问密钥为minioadmin。MinIO的API端口为9000,控制台端口为9001。

我们在浏览器中访问http://YOUR_IP:9001,使用minioadmin用户登录MinIO控制台,就看到了MinIO的首页。最后,我们创建一个名称为sporeaccounting的存储桶,作为孢子记账项目的存储根目录。

二、创建资源微服务

在这一小节,我们将创建资源微服务SP.ResourceService,这是一个专门为孢子记账项目提供文件资源管理的微服务。资源微服务将负责处理所有与文件相关的操作,包括用户头像和账单图片等资源的管理。通过与MinIO对象存储的集成,我们可以实现安全可靠的文件存储和访问机制。该服务将提供完整的文件生命周期管理功能,允许用户上传文件到MinIO存储系统,获取文件的访问URL以便前端展示,在不需要时删除文件释放存储空间。为了优化文件上传体验和提高性能,我们还将实现基于预签名URL的前端直传机制,客户端可以获取临时的上传凭证,直接将文件上传到MinIO存储,而无需经过应用服务器中转。在文件上传完成后,客户端会通知资源服务进行确认,以确保文件上传的完整性和有效性。这种设计不仅能提供良好的用户体验,还能有效减轻应用服务器的负载。

2.1 上传文件

我们首先实现上传文件功能,在Service文件夹下新建OSS服务接口IOssService,在这个接口中添加上传文件方法UploadAsync,代码如下:

/// <summary>
/// 上传文件
/// </summary>
/// <param name="file">文件</param>
/// <param name="isPublic">是否公开</param>
/// <param name="ct">取消令牌</param>
Task UploadAsync(IFormFile file, bool isPublic = true, CancellationToken ct = default);

UploadAsync方法是一个异步方法,用于将文件上传到MinIO对象存储服务。该方法接收IFormFile类型的file参数用于处理上传的文件,IFormFile是ASP.NET Core中用于处理HTTP请求中文件的接口,包含了文件的基本信息(如文件名、大小、内容类型等)和文件流;isPublic参数为bool类型且默认值为true,用于控制上传文件的访问权限,true表示文件可以通过URL直接访问,false表示需要授权才能访问;ct参数为CancellationToken类型且默认值为default,这是.NET中用于协调异步操作取消的标准机制,可以在长时间运行的文件上传过程中实现取消功能。

在实现类中,我们将使用MinIO的SDK来完成实际的文件上传工作。在Service/Impl文件夹下新建MinIo的实现类。

/// <summary>
/// MinIO 客户端
/// </summary>
private readonly IMinioClient _client;

/// <summary>
/// MinIO 配置选项
/// </summary>
private readonly IOptions<MinioOptions> _options;

/// <summary>
/// 日志记录器
/// </summary>
private readonly ILogger<MinioOssService> _logger;

/// <summary>
/// 数据库上下文
/// </summary>
private readonly ResourceServiceDbContext _dbContext;

/// <summary>
/// 构造函数
/// </summary>
/// <param name="options"></param>
/// <param name="logger"></param>
/// <param name="dbContext"></param>
public MinioOssService(IOptions<MinioOptions> options, ILogger<MinioOssService> logger,
    ResourceServiceDbContext dbContext)
{
    _options = options;
    _logger = logger;
    _dbContext = dbContext;

    try
    {
        // 验证配置
        ValidateConfiguration(options.Value);
        // 处理端点URL格式 - MinIO客户端期望的是主机名和端口,而不是完整的URL
        var endpoint = ProcessEndpoint(options.Value.Endpoint);
        // 初始化 MinIO 客户端
        var clientBuilder = new MinioClient()
            .WithEndpoint(endpoint)
            .WithCredentials(options.Value.AccessKey, options.Value.SecretKey);

        if (options.Value.WithSSL)
        {
            clientBuilder = clientBuilder.WithSSL();
        }

        // 构建客户端
        _client = clientBuilder.Build();
    }
    catch (Exception ex)
    {
        _logger.LogError(ex, "构建客户端失败");
        throw;
    }
}

/// <summary>
/// 上传文件
/// </summary>
/// <param name="file">文件</param>
/// <param name="isPublic">是否公开</param>
/// <param name="ct">取消令牌</param>
/// <returns></returns>
public async Task UploadAsync(IFormFile file, bool isPublic = true, CancellationToken ct = default)
{
    using var streamRead = file.OpenReadStream();
    var objectName = $"{DateTime.UtcNow:yyyy/MM/dd}/{Guid.NewGuid():N}{Path.GetExtension(file.FileName)}";
    var bucket = isPublic ? _options.Value.PublicBucket : _options.Value.PrivateBucket;
    await EnsureBucketAsync(bucket, ct);

    var putArgs = new PutObjectArgs();
    // 计算大小(若不可Seek,先缓冲)
    long size;
    if (streamRead.CanSeek)
    {
        size = streamRead.Length - streamRead.Position;
        putArgs.WithStreamData(streamRead);
    }
    else
    {
        var ms = new MemoryStream();
        await streamRead.CopyToAsync(ms, ct);
        ms.Position = 0;
        var stream = streamRead;
        stream = ms;
        size = ms.Length;
        putArgs.WithStreamData(stream);
    }

    putArgs.WithBucket(bucket)
        .WithObject(objectName)
        .WithObjectSize(size)
        .WithContentType(file.ContentType);

    await _client.PutObjectAsync(putArgs, ct);
    Files fileInfo = new Files
    {
        ObjectName = objectName,
        IsPublic = isPublic,
        Size = size,
        ContentType = file.ContentType,
        OriginalName = file.FileName
    };
    SettingCommProperty.Create(fileInfo);
    _dbContext.Files.Add(fileInfo);
    await _dbContext.SaveChangesAsync(ct);
}

首先,MinioOssService 类的构造函数接收了三个重要的依赖:MinIO配置选项、日志记录器和数据库上下文。在构造函数中,我们首先验证配置的有效性,然后处理MinIO的端点URL格式,因为MinIO客户端需要的是主机名和端口,而不是完整的URL。接着使用Builder模式构建MinIO客户端实例,如果配置了SSL,则启用安全连接。

UploadAsync方法中,我们实现了文件上传的核心逻辑。该方法首先打开文件流,并根据当前日期和GUID生成一个唯一的对象名称,这样可以避免文件名冲突。根据isPublic参数选择使用公开或私有存储桶,并确保存储桶存在。文件上传的过程中,我们需要特别处理文件流的大小计算。如果流支持Seek操作,我们可以直接获取其长度;如果不支持,则需要先将内容复制到内存流中进行缓冲。这样的处理确保了上传过程的可靠性。上传参数的设置也很关键,我们通过PutObjectArgs设置了存储桶名称、对象名称、文件大小和内容类型等必要信息。使用MinIO客户端的PutObjectAsync方法执行实际的上传操作。

最后,我们将文件信息保存到数据库中。创建Files实体对象,设置对象名称、公开状态、大小、内容类型和原始文件名等信息,并通过SettingCommProperty.Create设置通用属性(如创建时间、创建者等)。最后将实体添加到数据库上下文并保存更改。

在代码中我们调用了ProcessEndpointValidateConfigurationEnsureBucketAsync 三个方法,它们都是私有方法,用来辅助文件上传功能,我们来看一下这三个方法的代码:

/// <summary>
/// 确保桶存在
/// </summary>
/// <param name="bucket">桶名称</param>
/// <param name="ct">取消令牌</param>
/// <returns></returns>
private async Task EnsureBucketAsync(string bucket, CancellationToken ct)
{
    var exists = await _client.BucketExistsAsync(new BucketExistsArgs().WithBucket(bucket), ct);
    if (!exists)
    {
        await _client.MakeBucketAsync(new MakeBucketArgs().WithBucket(bucket), ct);
        _logger.LogInformation("Created bucket: {Bucket}", bucket);

        // 如果是公共桶,设置为公共访问策略
        if (bucket == _options.Value.PublicBucket)
        {
            await SetPublicBucketPolicyAsync(bucket, ct);
            _logger.LogInformation("Set public access policy for bucket: {Bucket}", bucket);
        }
    }
}

/// <summary>
/// 为桶设置公共访问策略
/// </summary>
/// <param name="bucket">桶名称</param>
/// <param name="ct">取消令牌</param>
/// <returns></returns>
private async Task SetPublicBucketPolicyAsync(string bucket, CancellationToken ct)
{
    try
    {
        // 创建公共访问策略JSON
        var policy = new
        {
            Version = "2012-10-17",
            Statement = new[]
            {
                new
                {
                    Effect = "Allow",
                    Principal = new { AWS = new[] { "*" } },
                    Action = new[] { "s3:GetObject" },
                    Resource = new[] { $"arn:aws:s3:::{bucket}/*" }
                }
            }
        };

        var policyJson = JsonSerializer.Serialize(policy, new JsonSerializerOptions
        {
            PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
            WriteIndented = false
        });

        // 使用SetPolicyAsync方法设置桶策略
        var setPolicyArgs = new SetPolicyArgs()
            .WithBucket(bucket)
            .WithPolicy(policyJson);

        await _client.SetPolicyAsync(setPolicyArgs, ct);
    }
    catch (Exception ex)
    {
        // 不抛出异常,因为桶已经创建成功,只是策略设置失败
        _logger.LogError(ex, "为桶设置公共访问策略失败: {Bucket}。桶仍可用,但对象无法通过URL直接访问。", bucket);
    }
}

/// <summary>
/// 验证MinIO配置
/// </summary>
/// <param name="options">MinIO配置选项</param>
/// <exception cref="ArgumentException">配置无效时抛出异常</exception>
private void ValidateConfiguration(MinioOptions options)
{
    if (string.IsNullOrWhiteSpace(options.Endpoint))
    {
        throw new ArgumentException("MinIO 端点不能为空");
    }

    if (string.IsNullOrWhiteSpace(options.AccessKey))
    {
        throw new ArgumentException("MinIO AccessKey 不能为空");
    }

    if (string.IsNullOrWhiteSpace(options.SecretKey))
    {
        throw new ArgumentException("MinIO SecretKey 不能为空");
    }

    if (string.IsNullOrWhiteSpace(options.PublicBucket))
    {
        throw new ArgumentException("MinIO 公共桶不能为空");
    }

    if (string.IsNullOrWhiteSpace(options.PrivateBucket))
    {
        throw new ArgumentException("MinIO 私有桶不能为空");
    }
}

/// <summary>
/// 处理端点URL格式
/// </summary>
/// <param name="endpoint">原始端点</param>
/// <returns>处理后的端点</returns>
private string ProcessEndpoint(string endpoint)
{
    // 如果端点包含协议前缀,需要移除
    if (endpoint.StartsWith("http://", StringComparison.OrdinalIgnoreCase))
    {
        endpoint = endpoint.Substring("http://".Length);
    }
    else if (endpoint.StartsWith("https://", StringComparison.OrdinalIgnoreCase))
    {
        endpoint = endpoint.Substring("https://".Length);
    }

    // 移除末尾的斜杠
    endpoint = endpoint.TrimEnd('/');

    return endpoint;
}

上述代码是上传文件功能的关键私有辅助方法,EnsureBucketAsync方法负责确保存储桶的存在和正确配置。方法首先检查指定的存储桶是否存在,如果不存在则创建新的存储桶。对于公共访问的存储桶(由配置中的PublicBucket指定),方法会额外调用SetPublicBucketPolicyAsync来设置适当的访问策略。这个方法保证了存储桶在使用前已经正确创建和配置。

SetPublicBucketPolicyAsync方法实现了为公共存储桶设置访问策略的功能。方法创建了一个符合AWS S3标准的策略JSON,允许所有用户对存储桶中的对象进行GetObject操作。策略使用JSON序列化,并通过MinIO客户端的SetPolicyAsync方法应用到存储桶。这个方法采用了容错设计,即使策略设置失败,也不会抛出异常而是记录错误日志,这样可以确保上传功能仍然可用,只是可能无法通过URL直接访问文件。

ValidateConfiguration方法用于验证MinIO配置的完整性和有效性。它检查了包括端点(Endpoint)、访问密钥(AccessKey)、密钥(SecretKey)以及公共和私有存储桶名称在内的所有必要配置项。如果任何配置项为空或未设置,方法会抛出ArgumentException异常,这种严格的配置验证确保了系统在启动时就能发现配置问题,避免运行时出现意外错误。

ProcessEndpoint方法处理MinIO服务端点URL的格式化。这个方法的存在是因为MinIO客户端需要特定格式的端点地址,它期望得到的是主机名和端口,而不是完整的URL。该方法会移除URL中的协议前缀(“http://“或"https://”)以及末尾的斜杠,确保端点地址符合MinIO客户端的要求。

最后我们新建控制器FilesController,并新增Action Upload,代码如下,代码很简单,这里就不再多讲了。

/// <summary>
/// 上传文件
/// </summary>
/// <param name="file"></param>
/// <param name="isPublic"></param>
/// <returns></returns>
[HttpPost("upload")]
[Consumes("multipart/form-data")]
public async Task<ActionResult> Upload(IFormFile file, [FromQuery] bool isPublic = true)
{
    await _oss.UploadAsync(file,isPublic);
    return Ok();
}
2.2 获取文件URL

接下来我们实现获取文件URL的功能。对于公开的文件,我们将返回一个可以直接访问的URL;对于私有文件,我们将生成一个带有时效性的预签名URL。这样可以确保文件访问的安全性,同时为用户提供便捷的访问方式。让我们看看具体的实现代码:

// =================IOssService===================

/// <summary>
/// 获取文件URL
/// </summary>
/// <param name="fileId">文件id</param>
Task<string> GetUrlAsync(long fileId);

// =================MinioOssService===================

/// <summary>
/// 获取文件URL
/// </summary>
/// <param name="fileId">文件id</param>
/// <returns></returns>
public async Task<string> GetUrlAsync(long fileId)
{
    // 查询文件信息
    Files? file = _dbContext.Files.FirstOrDefault(f=>f.Id==fileId && f.IsDeleted== false);
    if (file == null)
    {
        throw new NotFoundException("文件不存在");
    }

    string bucket = "";
    string objectName = file.ObjectName;
    if (file.IsPublic)
    {
        bucket = _options.Value.PublicBucket;
        // 公开桶:返回直链
        var baseUrl = _options.Value.PublicBaseUrl?.TrimEnd('/');
        if (!string.IsNullOrWhiteSpace(baseUrl))
        {
            return $"{baseUrl}/{bucket}/{Uri.EscapeDataString(objectName)}";
        }

        // 若未配置 PublicBaseUrl,则退回到 MinIO 原始地址
        var scheme = _options.Value.WithSSL ? "https" : "http";
        return $"{scheme}://{_options.Value.Endpoint.TrimEnd('/')}/{bucket}/{Uri.EscapeDataString(objectName)}";
    }
    else
    {
        // 私有桶:返回预签名
        bucket = _options.Value.PrivateBucket;
        var expirySeconds = _options.Value.PresignedExpireSeconds;
        var preArgs = new PresignedGetObjectArgs()
            .WithBucket(bucket)
            .WithObject(objectName)
            .WithExpiry(expirySeconds);
        return await _client.PresignedGetObjectAsync(preArgs);
    }
}

GetUrlAsync方法是一个关键的文件访问URL生成功能。该方法通过文件ID从数据库查询文件信息,实现了对公开和私有文件的不同访问策略处理。方法首先会验证文件的存在性和有效性,如果找不到文件或文件已被标记为删除,会抛出NotFoundException异常来及时通知调用方。

在处理公开文件时,方法采用了灵活的URL生成策略。它优先使用配置中的PublicBaseUrl,这通常用于已配置CDN或自定义域名的场景。通过将PublicBaseUrl、存储桶名称和经过URL编码的对象名称组合,生成一个可直接访问的完整URL。如果没有配置PublicBaseUrl,方法会降级使用MinIO服务器的原始地址,根据WithSSL配置选择合适的协议(http或https),确保在任何情况下都能生成有效的访问地址。

对于私有文件的处理则采用了更安全的方式。方法使用MinIO客户端的PresignedGetObjectAsync功能生成带有访问凭证的预签名URL。这个URL具有时效性,其有效期由配置中的PresignedExpireSeconds控制。预签名URL包含了必要的认证信息,确保只有获得授权的用户在规定时间内能够访问文件,既保证了便捷访问,又不影响安全性。这种机制适合需要临时授权访问的场景,比如文件预览或限时下载。

Tip:Controller 中的调用方式很简单,就不再展示了,大家在项目的 中查看完整代码。

2.3 删除文件

在资源管理中,删除功能是不可或缺的一部分。当用户不再需要某个文件时,我们需要同时清理MinIO存储中的实际文件和数据库中的文件记录。这个功能需要谨慎实现,因为文件删除是不可逆的操作。我们的实现会先验证文件的存在性,然后依次执行存储清理和数据库更新操作,确保整个删除过程的原子性和可靠性。让我们来看看具体的实现代码:

// =================IOssService===================

/// <summary>
/// 删除文件
/// </summary>
/// <param name="fileId">文件id</param>
/// <param name="ct">取消令牌</param>
Task DeleteAsync(long fileId, CancellationToken ct = default);

// =================MinioOssService==================

/// <summary>
/// 删除文件
/// </summary>
/// <param name="fileId">文件id</param>
/// <param name="ct">取消令牌</param>
/// <returns></returns>
public async Task DeleteAsync(long fileId, CancellationToken ct = default)
{
    // 查询文件
    Files? fileInfo = _dbContext.Files.FirstOrDefault(f => f.IsDeleted == false && f.Id == fileId);
    if (fileInfo == null)
    {
        throw new NotFoundException("文件不存在");
    }

    var bucket = fileInfo.IsPublic ? _options.Value.PublicBucket : _options.Value.PrivateBucket;
    await EnsureBucketAsync(bucket, ct);

    var rmArgs = new RemoveObjectArgs()
        .WithBucket(bucket)
        .WithObject(fileInfo.ObjectName);

    await _client.RemoveObjectAsync(rmArgs, ct);
    // 删除数据库记录
    SettingCommProperty.Delete(fileInfo);
    await _dbContext.SaveChangesAsync(fileInfo);
}

删除文件功能是资源管理中的重要组成部分。在IOssService接口中,我们定义了DeleteAsync方法,该方法接收文件ID和取消令牌作为参数。这个异步方法负责安全地删除存储在MinIO中的文件以及相关的数据库记录。

MinioOssService的具体实现中,DeleteAsync方法首先通过LINQ查询从数据库中获取文件信息。查询条件确保只查找未删除的文件(IsDeleted为false)且ID匹配的记录。如果找不到符合条件的文件记录,方法会抛出NotFoundException异常,及时通知调用方文件不存在。

获取到文件信息后,方法会根据文件的IsPublic属性决定从哪个存储桶中删除文件。通过_options.Value访问配置信息,如果是公开文件则使用PublicBucket,否则使用PrivateBucket。在执行删除操作前,方法会调用EnsureBucketAsync确保目标存储桶存在,这是一个额外的安全检查。接下来,方法使用MinIO客户端的RemoveObjectAsync方法执行实际的文件删除操作。RemoveObjectArgs通过链式调用配置了必要的参数,包括存储桶名称和文件的对象名称。这个操作会从MinIO存储中物理删除文件。最后我们调用_dbContext.SaveChangesAsync(ct);方法将文件标记为已删除。

2.4 获取前端直传凭证

在实际的文件上传场景中,我们经常需要考虑前端直接上传文件到对象存储服务的需求。这种直传方案可以有效减轻应用服务器的负载,提升上传性能和用户体验。为了实现这个功能,我们需要提供一个预签名的上传URL给前端使用。这个预签名URL本质上是一个临时的、带有授权信息的上传端点,它允许客户端在限定时间内直接向对象存储服务发起上传请求,而无需通过应用服务器中转文件数据。

预签名URL的工作机制是通过在URL中嵌入临时的访问凭证和必要的参数信息,使得客户端能够在有限时间内执行特定的操作(在这里是上传文件)。当前端获取到这个预签名URL后,就可以直接使用标准的HTTP PUT请求将文件上传到这个地址。这种方式不仅能显著提升上传效率,还能减少服务器的带宽消耗和处理负担。同时,由于预签名URL具有时效性和操作限制,也保证了上传操作的安全性。在大文件上传或高并发场景下,这种直传方案的优势尤为明显。我们来看一下代码实现:

// =================IOssService===================

/// <summary>
/// 生成用于前端直传的预签名 PUT URL
/// </summary>
/// <param name="fileName">对象名称</param>
/// <param name="isPublic">是否公开桶</param>
/// <param name="ct">取消令牌</param>
Task<PresignedURLResponse> GetPresignedPutUrlAsync(string fileName, bool isPublic, CancellationToken ct = default);

// =================MinioOssService==================

/// <summary>
/// 生成前端直传的预签名 PUT URL
/// </summary>
/// <param name="fileName"></param>
/// <param name="isPublic"></param>
/// <param name="ct"></param>
public async Task<PresignedURLResponse> GetPresignedPutUrlAsync(string fileName, bool isPublic,
    CancellationToken ct = default)
{
    // 拼接日期路径和唯一标识
    string objectName = $"{DateTime.UtcNow:yyyy/MM/dd}/{Guid.NewGuid():N}{Path.GetExtension(fileName)}";
    var bucket = isPublic ? _options.Value.PublicBucket : _options.Value.PrivateBucket;
    await EnsureBucketAsync(bucket, ct);

    int expirySeconds = _options.Value.UploadTokenExpireSeconds;
    var preArgs = new PresignedPutObjectArgs()
        .WithBucket(bucket)
        .WithObject(objectName)
        .WithExpiry(expirySeconds);

    string uploadUrl = await _client.PresignedPutObjectAsync(preArgs);
    PresignedURLResponse presignedUrlResponse = new PresignedURLResponse()
    {
        UploadUrl = uploadUrl,
        ObjectName = objectName,
        OriginalFileName = fileName
    };
    return presignedUrlResponse;
}

IOssService接口中,我们定义了GetPresignedPutUrlAsync方法,该方法接收文件名、是否公开访问的标志以及取消令牌作为参数,返回一个包含预签名上传URLPresignedURLResponse对象。

MinioOssService的具体实现中,GetPresignedPutUrlAsync方法首先通过组合当前UTC时间的年月日路径格式和一个GUID,生成一个唯一的对象名称,并保留原始文件的扩展名。这种命名方式既保证了文件名的唯一性,又实现了按日期的文件组织结构。根据isPublic参数,方法会选择使用公开桶还是私有桶,并通过EnsureBucketAsync方法确保目标存储桶存在。

接下来,方法从配置中获取上传令牌的过期时间UploadTokenExpireSeconds,并使用MinIO客户端的PresignedPutObjectArgs创建预签名请求参数。这些参数包括存储桶名称、对象名称和过期时间。通过调用MinIO客户端的PresignedPutObjectAsync方法,生成一个带有认证信息的临时上传URL。

最后,方法将生成的上传URL、对象名称和原始文件名封装到PresignedURLResponse对象中返回。这个响应对象包含了客户端执行直传所需的所有信息。前端可以使用返回的预签名URL直接向MinIO发起PUT请求上传文件,既提高了上传效率,又减轻了应用服务器的负担。

2.5 确认文件上传成功

在前端完成文件上传到MinIO存储后,系统需要一个确认机制来验证上传是否成功并将文件信息持久化到数据库中。这个确认过程对于维护文件系统的完整性和一致性至关重要。前端会将文件的关键信息,包括对象名称、文件大小、内容类型、原始文件名等发送到服务端。服务端首先会通过MinIO的API验证文件是否确实存在于存储桶中,确保文件上传的完整性。验证通过后,服务端会在数据库中创建相应的文件记录,建立起文件元数据与实际存储对象之间的映射关系。这种双重确认机制不仅能够及时发现上传过程中的问题,还能确保系统中的文件记录始终与实际存储的文件保持同步,为后续的文件管理和访问提供可靠的基础。实现代码如下:

// =================IOssService===================

/// <summary>
/// 文件上传确认
/// </summary>
/// <param name="request">上传确认请求</param>
/// <returns></returns>
Task ConfirmUploadAsync(ConfirmUploadRequest request, CancellationToken ct = default);

// =================MinioOssService==================

/// <summary>
/// 文件上传确认
/// </summary>
/// <param name="request"></param>
/// <returns></returns>
public async Task ConfirmUploadAsync(ConfirmUploadRequest request, CancellationToken ct = default)
{
    // 验证文件是否真的存在于 MinIO 中
    var bucket = request.IsPublic ? _options.Value.PublicBucket : _options.Value.PrivateBucket;
    try
    {
        var statArgs = new StatObjectArgs()
            .WithBucket(bucket)
            .WithObject(request.ObjectName);

        var objectStat = await _client.StatObjectAsync(statArgs, ct);

        // 创建文件记录
        var fileInfo = new Files
        {
            ObjectName = request.ObjectName,
            IsPublic = request.IsPublic,
            Size = request.FileSize,
            ContentType = request.ContentType,
            OriginalName = request.OriginalFileName
        };

        SettingCommProperty.Create(fileInfo);
        _dbContext.Files.Add(fileInfo);
        await _dbContext.SaveChangesAsync(ct);
    }
    catch (Exception ex)
    {
        _logger.LogError(ex, "无法确认文件上传:{ObjectName}", request.ObjectName);
        throw new BadRequestException($"无法确认文件上传: {request.ObjectName}");
    }
}

文件上传确认功能是确保文件成功上传到MinIO存储系统的重要环节。在IOssService接口中,我们定义了ConfirmUploadAsync方法,该方法接收一个ConfirmUploadRequest类型的请求参数和一个可选的取消令牌参数。这个方法的主要职责是验证文件是否确实存在于MinIO存储中,并在确认成功后在数据库中创建相应的文件记录。

MinioOssService中的具体实现中,首先根据请求中的IsPublic属性决定使用公共存储桶还是私有存储桶。这种设计允许系统灵活处理不同访问权限的文件存储需求。接下来,通过创建StatObjectArgs对象并设置相应的存储桶和对象名称,使用MinIO客户端的StatObjectAsync方法来验证文件的存在性。这个操作会检查文件的元数据,如果文件不存在或无法访问,将会抛出异常。

如果文件验证成功,方法会创建一个新的Files实体对象,用于在数据库中记录文件信息。这个实体包含了文件的关键属性:对象名称、公共访问标志、文件大小、内容类型以及原始文件名。通过SettingCommProperty.Create方法设置通用属性后,将文件记录添加到数据库上下文中,并通过SaveChangesAsync保存更改。

为了确保系统的健壮性,整个操作被包装在try-catch块中。如果在验证或保存过程中发生任何异常,会记录详细的错误日志,并抛出一个BadRequestException异常,提供清晰的错误信息给调用方。

三、总结

本文详细介绍了在孢子记账项目中如何集成和使用MinIO对象存储服务来管理用户头像和账单图片等文件资源。文章首先深入讲解了MinIO的核心特性、优势以及在现代云原生应用中的广泛应用场景,并通过Docker方式演示了MinIO的安装部署过程。随后,文章重点展示了如何构建资源微服务,实现了包括文件上传和URL获取等在内的核心功能。在实现过程中,不仅考虑了公共访问和私有访问两种场景,还通过预签名URL机制确保了文件访问的安全性。