前面我们完成了设置管理,接下来正好配合设置管理来实现文件管理功能。
文件管理自然包括文件上传,下载以及文件存储功能。设计要求可以支持扩展多种存储服务,如本地文件,云存储等等。
数据库设计
首先当然是我们的数据库表设计,用于管理文件。创建一个文件信息存储表。
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; } }}
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类型的包装。后面可根据需求再加上细分类型。
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中添加代码:
#region FileStoragepublic DbSet<FileStorage> FileStorages { get; set; }#endregionprotected 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基础接口。
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
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
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
基础的对接搭好了,现在我们来实现我们的业务功能。很简单,就三个功能,上传下载,分页查询。
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); }}
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。
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对外。
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,点击即可下载出来


测试顺利完成,到这我们就完成了我们简单的文件管理功能了。
轮子仓库地址https://github.com/Wheel-Framework/Wheel
欢迎进群催更。
