造轮子之文件管理

2023-10-25 14:34:20 浏览数 (1)

前面我们完成了设置管理,接下来正好配合设置管理来实现文件管理功能。 文件管理自然包括文件上传,下载以及文件存储功能。设计要求可以支持扩展多种存储服务,如本地文件,云存储等等。

数据库设计

首先当然是我们的数据库表设计,用于管理文件。创建一个文件信息存储表。

代码语言:javascript复制
using Wheel.Domain.Common;
using Wheel.Enums;

namespace Wheel.Domain.FileStorages
{
    /// <summary>
    /// 文件信息存储表
    /// </summary>
    public class FileStorage : Entity, IHasCreationTime
    {
        /// <summary>
        /// 文件名
        /// </summary>
        public string FileName { get; set; }
        /// <summary>
        /// 文件类型ContentType
        /// </summary>
        public string ContentType { get; set; }
        /// <summary>
        /// 文件类型
        /// </summary>
        public FileStorageType FileStorageType { get; set; }
        /// <summary>
        /// 大小
        /// </summary>
        public long Size { get; set; }
        /// <summary>
        /// 存储路径
        /// </summary>
        public string Path { get; set; }
        /// <summary>
        /// 创建时间
        /// </summary>
        public DateTimeOffset CreationTime { get; set; }
        /// <summary>
        /// 存储类型
        /// </summary>
        public string Provider { get; set; }
    }
}
代码语言:javascript复制
namespace Wheel.Enums
{
    public enum FileStorageType
    {
        /// <summary>
        /// 普通文件
        /// </summary>
        File = 0,
        /// <summary>
        /// 图片
        /// </summary>
        Image = 1,
        /// <summary>
        /// 视频
        /// </summary>
        Video = 2,
        /// <summary>
        /// 音频
        /// </summary>
        Audio = 3,
        /// <summary>
        /// 文本类型
        /// </summary>
        Text = 4,
    }
}

FileStorageType是对ContentType类型的包装。后面可根据需求再加上细分类型。

代码语言:javascript复制
using Wheel.Enums;

namespace Wheel.Domain.FileStorages
{
    public static class FileStorageTypeChecker
    {
        public static FileStorageType CheckFileType(string contentType)
        {
            return contentType switch
            {
                var _ when contentType.StartsWith("audio") => FileStorageType.Audio,
                var _ when contentType.StartsWith("image") => FileStorageType.Image,
                var _ when contentType.StartsWith("text") => FileStorageType.Text,
                var _ when contentType.StartsWith("video") => FileStorageType.Video,
                _ => FileStorageType.File
            };
        }
    }
}

Provider对应不同的存储服务。如Minio等。

修改DbContext

在DbContext中添加代码:

代码语言:javascript复制
#region FileStorage
public DbSet<FileStorage> FileStorages { get; set; }
#endregion


protected override void OnModelCreating(ModelBuilder builder)
{
    base.OnModelCreating(builder);

    ConfigureIdentity(builder);
    ConfigureLocalization(builder);
    ConfigurePermissionGrants(builder);
    ConfigureMenus(builder);
    ConfigureSettings(builder);
    ConfigureFileStorage(builder);
}

void ConfigureFileStorage(ModelBuilder builder)
{
    builder.Entity<FileStorage>(b =>
    {
        b.HasKey(o => o.Id);
        b.Property(o => o.FileName).HasMaxLength(256);
        b.Property(o => o.Path).HasMaxLength(256);
        b.Property(o => o.ContentType).HasMaxLength(32);
        b.Property(o => o.Provider).HasMaxLength(32);
    });
}

然后执行数据库迁移操作即可完成表创建。

FileStorageProvider

接下来就是实现我们的文件存储的Provider,首先创建一个IFileStorageProvider基础接口。

代码语言:javascript复制
using Wheel.DependencyInjection;

namespace Wheel.FileStorages
{
    public interface IFileStorageProvider : ITransientDependency
    {
        string Name { get; }

        Task<UploadFileResult> Upload(UploadFileArgs uploadFileArgs, CancellationToken cancellationToken = default);
        Task<DownFileResult> Download(DownloadFileArgs downloadFileArgs, CancellationToken cancellationToken = default);

        Task<object> GetClient();

        void ConfigureClient<T>(Action<T> configure);

    }
}

提供定义名称,上传下载,以及获取Provider的Client和配置Provider中的Client的方法。

FileProviderSettingDefinition

既然要对接各种存储服务,那么当然少不了对接的配置,那么我们就基于前面设置管理。添加一个FileProviderSettingDefinition

代码语言:javascript复制
using Wheel.Enums;

namespace Wheel.Settings.FileProvider
{
    public class FileProviderSettingDefinition : ISettingDefinition
    {
        public string GroupName => "FileProvider";

        public SettingScope SettingScope => SettingScope.Global;

        public Dictionary<string, SettingValueParams> Define()
        {
            return new Dictionary<string, SettingValueParams>
            {
                { "Minio.Endpoint", new(SettingValueType.String, "127.0.0.1:9000") },
                { "Minio.AccessKey", new(SettingValueType.String, "2QgNxo11uxgULRvkrdaT") },
                { "Minio.SecretKey", new(SettingValueType.String, "NvzXnh81UMwEcvLJc8BslA1GA0j0sCq0aXRgHSRJ") },
                { "Minio.Region", new(SettingValueType.String) },
                { "Minio.SessionToken", new(SettingValueType.String) }
            };
        }
    }
}

这里我暂时只实现对接Minio,所以只加上Minio的配置。

MinioFileStorageProvider

接下来实现一个MinioFileStorageProvider

代码语言:javascript复制
using Minio;
using Minio.DataModel.Args;
using Minio.Exceptions;
using Wheel.Settings;

namespace Wheel.FileStorages.Providers
{
    public class MinioFileStorageProvider : IFileStorageProvider
    {
        private readonly ISettingProvider _settingProvider;
        private readonly ILogger<MinioFileStorageProvider> _logger;

        public MinioFileStorageProvider(ISettingProvider settingProvider, ILogger<MinioFileStorageProvider> logger)
        {
            _settingProvider = settingProvider;
            _logger = logger;
        }

        public string Name => "Minio";
        internal Action<IMinioClient>? Configure { get; private set; }
        public async Task<UploadFileResult> Upload(UploadFileArgs uploadFileArgs, CancellationToken cancellationToken = default)
        {
            var client = await GetMinioClient();
            try
            {
                // Make a bucket on the server, if not already present.
                var beArgs = new BucketExistsArgs()
                    .WithBucket(uploadFileArgs.BucketName);
                bool found = await client.BucketExistsAsync(beArgs, cancellationToken).ConfigureAwait(false);
                if (!found)
                {
                    var mbArgs = new MakeBucketArgs()
                        .WithBucket(uploadFileArgs.BucketName);
                    await client.MakeBucketAsync(mbArgs, cancellationToken).ConfigureAwait(false);
                }
                // Upload a file to bucket.
                var putObjectArgs = new PutObjectArgs()
                    .WithBucket(uploadFileArgs.BucketName)
                    .WithObject(uploadFileArgs.FileName)
                    .WithStreamData(uploadFileArgs.FileStream)
                    .WithObjectSize(uploadFileArgs.FileStream.Length)
                    .WithContentType(uploadFileArgs.ContentType);
                await client.PutObjectAsync(putObjectArgs, cancellationToken).ConfigureAwait(false);
                var path = BuildPath(uploadFileArgs.BucketName, uploadFileArgs.FileName);
                _logger.LogInformation("Successfully Uploaded "   path);
                return new UploadFileResult { FilePath = path, Success = true };
            }
            catch (MinioException e)
            {
                _logger.LogError("File Upload Error: {0}", e.Message);
                return new UploadFileResult { Success = false };
            }
        }
        public async Task<DownFileResult> Download(DownloadFileArgs downloadFileArgs, CancellationToken cancellationToken = default)
        {
            var client = await GetMinioClient();
            try
            {
                var stream = new MemoryStream();
                var args = downloadFileArgs.Path.Split("/");
                var getObjectArgs = new GetObjectArgs()
                    .WithBucket(args[0])
                    .WithObject(downloadFileArgs.Path.RemovePreFix($"{args[0]}/"))
                    .WithCallbackStream(fs => fs.CopyTo(stream))
                    ;
                var response = await client.GetObjectAsync(getObjectArgs, cancellationToken).ConfigureAwait(false);

                _logger.LogInformation("Successfully Download "   downloadFileArgs.Path);
                stream.Position = 0;
                return new DownFileResult { Stream = stream, Success = true, FileName = response.ObjectName, ContentType = response.ContentType };
            }
            catch (MinioException e)
            {
                _logger.LogError("File Download Error: {0}", e.Message);
                return new DownFileResult { Success = false };
            }
        }

        public async Task<object> GetClient()
        {
            return await GetMinioClient();
        }

        public void ConfigureClient<T>(Action<T> configure)
        {
            if (typeof(T) == typeof(IMinioClient))
                Configure = configure as Action<IMinioClient>;
            else
                throw new Exception("MinioFileProvider ConfigureClient Only Can Configure Type With IMinioClient");
        }

        private async Task<IMinioClient> GetMinioClient()
        {
            var minioSetting = await GetSettings();
            var client = new MinioClient()
                .WithHttpClient(new HttpClient())
                .WithEndpoint(minioSetting["Endpoint"])
                .WithCredentials(minioSetting["AccessKey"], minioSetting["SecretKey"])
                .WithSessionToken(minioSetting["SessionToken"]);

            if (!string.IsNullOrWhiteSpace(minioSetting["Region"]))
            {
                client.WithRegion(minioSetting["Region"]);
            }

            if (Configure != null)
            {
                Configure.Invoke(client);
            }
            return client;
        }

        private async Task<Dictionary<string, string>> GetSettings()
        {
            var settings = await _settingProvider.GetGolbalSettings("FileProvider");

            return settings.Where(a => a.Key.StartsWith("Minio")).ToDictionary(a => a.Key.RemovePreFix("Minio."), a => a.Value);
        }
        private string BuildPath(string bucketName, string fileName)
        {
            return string.Join('/', bucketName, fileName);
        }
    }
}

这里定义MinioFileStorageProvider的Name是Minio用作标识。 Upload和Download则是正常的使用MinioClient的上传下载操作。 GetClient()返回一个MinioClient实例,用于方便做其他“骚操作”。 ConfigureClient则是用来配置MinioClient实例,代码约定限制只支持IMinioClient的类型。 GetSettings则是从SettingProvider中获取Minio的配置信息。

FileStorageManageAppService

基础的对接搭好了,现在我们来实现我们的业务功能。很简单,就三个功能,上传下载,分页查询。

代码语言:javascript复制
using Wheel.Core.Dto;
using Wheel.DependencyInjection;
using Wheel.Services.FileStorageManage.Dtos;

namespace Wheel.Services.FileStorageManage
{
    public interface IFileStorageManageAppService : ITransientDependency
    {
        Task<Page<FileStorageDto>> GetFileStoragePageList(FileStoragePageRequest request);
        Task<R<List<FileStorageDto>>> UploadFiles(UploadFileDto uploadFileDto);
        Task<R<DownloadFileResonse>> DownloadFile(long id);
    }
}
代码语言:javascript复制
using Wheel.Const;
using Wheel.Core.Dto;
using Wheel.Core.Exceptions;
using Wheel.Domain;
using Wheel.Domain.FileStorages;
using Wheel.Enums;
using Wheel.FileStorages;
using Wheel.Services.FileStorageManage.Dtos;
using Path = System.IO.Path;

namespace Wheel.Services.FileStorageManage
{
    public class FileStorageManageAppService : WheelServiceBase, IFileStorageManageAppService
    {
        private readonly IBasicRepository<FileStorage, long> _fileStorageRepository;

        public FileStorageManageAppService(IBasicRepository<FileStorage, long> fileStorageRepository)
        {
            _fileStorageRepository = fileStorageRepository;
        }

        public async Task<Page<FileStorageDto>> GetFileStoragePageList(FileStoragePageRequest request)
        {
            var (items, total) = await _fileStorageRepository.GetPageListAsync(
                _fileStorageRepository.BuildPredicate(
                    (!string.IsNullOrWhiteSpace(request.FileName), f => f.FileName.Contains(request.FileName!)),
                    (!string.IsNullOrWhiteSpace(request.ContentType), f => f.ContentType.Equals(request.ContentType)),
                    (!string.IsNullOrWhiteSpace(request.Path), f => f.Path.StartsWith(request.Path!)),
                    (!string.IsNullOrWhiteSpace(request.Provider), f => f.Provider.Equals(request.Provider)),
                    (request.FileStorageType.HasValue, f => f.FileStorageType.Equals(request.FileStorageType))
                    ),
                (request.PageIndex -1) * request.PageSize,
                request.PageSize,
                request.OrderBy
                );

            return new Page<FileStorageDto>(Mapper.Map<List<FileStorageDto>>(items), total);
        }
        public async Task<R<List<FileStorageDto>>> UploadFiles(UploadFileDto uploadFileDto)
        {
            var files = uploadFileDto.Files;
            if (files.Count == 0)
                return new R<List<FileStorageDto>>(new());
            IFileStorageProvider? fileStorageProvider = null;
            var fileStorageProviders = ServiceProvider.GetServices<IFileStorageProvider>();
            if (string.IsNullOrWhiteSpace(uploadFileDto.Provider))
            {
                fileStorageProvider = fileStorageProviders.First();
            }
            else
            {
                fileStorageProvider = fileStorageProviders.First(a => a.Name == uploadFileDto.Provider);
            }
            var fileStorages = new List<FileStorage>();
            foreach (var file in files) 
            {
                var fileName = uploadFileDto.Cover ? file.FileName : $"{Path.GetFileNameWithoutExtension(file.FileName)}-{SnowflakeIdGenerator.Create()}{Path.GetExtension(file.FileName)}";
                var fileStream = file.OpenReadStream();
                var fileStorageType = FileStorageTypeChecker.CheckFileType(file.ContentType);
                var uploadFileArgs = new UploadFileArgs 
                {
                    BucketName = fileStorageType switch
                    {
                        FileStorageType.Image => "images",
                        FileStorageType.Video => "videos",
                        FileStorageType.Audio => "audios",
                        FileStorageType.Text => "texts",
                        _ => "files"
                    },
                    ContentType = file.ContentType,
                    FileName = fileName,
                    FileStream = fileStream
                };
                var uploadFileResult = await fileStorageProvider.Upload(uploadFileArgs);

                if (uploadFileResult.Success)
                {
                    var fileStorage = await _fileStorageRepository.InsertAsync(new FileStorage 
                    {
                        Id = SnowflakeIdGenerator.Create(),
                        ContentType = file.ContentType,
                        FileName = file.FileName,
                        FileStorageType = fileStorageType,
                        Path = uploadFileResult.FilePath,
                        Provider = fileStorageProvider.Name,
                        Size = fileStream.Length
                    });
                    await _fileStorageRepository.SaveChangeAsync();
                    fileStorages.Add(fileStorage);
                }
            }
            return new R<List<FileStorageDto>>(Mapper.Map<List<FileStorageDto>>(fileStorages));
        }

        public async Task<R<DownloadFileResonse>> DownloadFile(long id)
        {
            var fileStorage = await _fileStorageRepository.FindAsync(id);
            if(fileStorage == null) 
            {
                throw new BusinessException(ErrorCode.FileNotExist, "FileNotExist")
                    .WithMessageDataData(id.ToString());
            }
            var fileStorageProvider = ServiceProvider.GetServices<IFileStorageProvider>().First(a=>a.Name == fileStorage.Provider);

            var downloadResult = await fileStorageProvider.Download(new DownloadFileArgs { Path = fileStorage.Path });
            if (downloadResult.Success)
            {
                return new R<DownloadFileResonse>(new DownloadFileResonse { ContentType = downloadResult.ContentType, FileName = downloadResult.FileName, Stream = downloadResult.Stream });
            }
            else
            {
                throw new BusinessException(ErrorCode.FileDownloadFail, "FileDownloadFail")
                    .WithMessageDataData(id.ToString());
            }
        }
    }
}

UploadFiles时如果没有指定Provider则默认取依赖注入第一个Provider,如果指定则取Provider。

代码语言:javascript复制
using Microsoft.AspNetCore.Mvc;

namespace Wheel.Services.FileStorageManage.Dtos
{
    public class UploadFileDto
    {
        [FromQuery]
        public bool Cover { get; set; } = false;

        [FromQuery]
        public string? Provider { get; set; }

        [FromForm]
        public IFormFileCollection Files { get; set; }
    }
}

这里上传参数定义,Cover表示是否覆盖原文件,Provider表示指定那种存储服务。Files则是从Form表单中读取文件流。

FileController

接下来就是把Service包成API对外。

代码语言:javascript复制
using Microsoft.AspNetCore.Mvc;
using Wheel.Core.Dto;
using Wheel.Services.FileStorageManage;
using Wheel.Services.FileStorageManage.Dtos;

namespace Wheel.Controllers
{
    /// <summary>
    /// 文件管理
    /// </summary>
    [Route("api/[controller]")]
    [ApiController]
    public class FileController : WheelControllerBase
    {
        private readonly IFileStorageManageAppService _fileStorageManageAppService;

        public FileController(IFileStorageManageAppService fileStorageManageAppService)
        {
            _fileStorageManageAppService = fileStorageManageAppService;
        }
        /// <summary>
        /// 分页查询列表
        /// </summary>
        /// <param name="request"></param>
        /// <returns></returns>
        [HttpGet]
        public Task<Page<FileStorageDto>> GetFileStoragePageList([FromQuery] FileStoragePageRequest request)
        {
            return _fileStorageManageAppService.GetFileStoragePageList(request);
        }
        /// <summary>
        /// 上传文件
        /// </summary>
        /// <param name="uploadFileDto"></param>
        /// <returns></returns>
        [HttpPost]
        public Task<R<List<FileStorageDto>>> UploadFiles(UploadFileDto uploadFileDto)
        {
            return _fileStorageManageAppService.UploadFiles(uploadFileDto);
        }
        /// <summary>
        /// 下载文件
        /// </summary>
        /// <param name="id"></param>
        /// <returns></returns>
        [HttpGet("{id}")]
        public async Task<IActionResult> DownloadFile(long id)
        {
            var result = await _fileStorageManageAppService.DownloadFile(id);
            return File(result.Data.Stream, result.Data.ContentType, result.Data.FileName);
        }
    }
}

DownloadFile返回一个FileResult,浏览器会自动下载。

测试

这里我使用本地的Minio服务进行测试。 查询

上传

可以看到我们FileName和Path不一样,默认不覆盖的情况,所有文件在后面自动拼接雪花Id。 下载文件

这里swagger可以看到有个Download file,点击即可下载出来

测试顺利完成,到这我们就完成了我们简单的文件管理功能了

0 人点赞