造轮子之文件管理
前面我们完成了设置管理,接下来正好配合设置管理来实现文件管理功能。
文件管理自然包括文件上传,下载以及文件存储功能。设计要求可以支持扩展多种存储服务,如本地文件,云存储等等。
数据库设计
首先当然是我们的数据库表设计,用于管理文件。创建一个文件信息存储表。
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 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基础接口。
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
欢迎进群催更。