文件服务
功能
1、本服务并不是直接存放用户上传文件的,只是一个中转服务,会将用户上传的文件、音频、图片放到云服务器、备份服务器
2、当用户上传服务器中已经存在的文件时,文件服务器会直接把之前的文件路径返回给上传者。
领域层
领域模型“上传项”(UploadedItem),每一次用户上传的文件就是一个“上传项”
实体类设计:文件实体
实体类设计
上传的每一份文件都对应一个UploadedItem
属性:Id+领域事件的操作、创建时间、文件大小(字节)、(原始)文件名、SHA256散列值、备份文件路径(内网)、外网路径
方法:Create静态方法
public record UploadedItem : BaseEntity, IHasCreationTime
{
public DateTime CreationTime { get; private set; }
//CreationTime文件上传时间
/// <summary>
/// 文件大小(尺寸为字节,为了方便比较大小不存值对象)
/// </summary>
public long FileSizeInBytes { get; private set; }
/// <summary>
/// 用户上传的原始文件名(没有路径)
/// </summary>
public string FileName { get; private set; }
/// <summary>
/// 两个文件的大小和散列值(SHA256)都相同的概率非常小。因此只要大小和SHA256相同,就认为是相同的文件。
/// SHA256的碰撞的概率比MD5低很多。
/// </summary>
public string FileSHA256Hash { get; private set; }
/// <summary>
/// 备份文件路径,因为可能会更换文件存储系统或者云存储供应商,因此系统会保存一份自有的路径。
/// 备份文件一般放到内网的高速、稳定设备上,比如NAS等。
/// </summary>
public Uri BackupUrl { get; private set; }
/// <summary>
/// 上传的文件的供外部访问者访问的云存储环境的路径
/// </summary>
public Uri RemoteUrl { get; private set; }
//Create创建文件对象的实例
public static UploadedItem Create(Guid id, long fileSizeInBytes, string fileName, string fileSHA256Hash, Uri backupUrl, Uri remoteUrl)
{
UploadedItem item = new UploadedItem()
{
Id = id,
CreationTime = DateTime.Now,
FileName = fileName,
FileSHA256Hash = fileSHA256Hash,
FileSizeInBytes = fileSizeInBytes,
BackupUrl = backupUrl,
RemoteUrl = remoteUrl
};
return item;
}
}
开发文件服务的领域服务FSDomainService,体现业务逻辑
领域服务:上传文件的逻辑
UploadAsync逻辑:
1)计算文件散列值、文件大小、当前时间,得到一个文件存储路径
因为一个文件夹下文件个数有一定限制,所以放到不同的文件夹。这里采用按照上传日期的年、月、日、散列值、原始文件名层级策略类存放文件,这样可以保存原始的文件名。用日期把文件分散在不同文件夹存储,同时由于加上了文件hash值作为目录,又用用户上传的文件做文件名,所以几乎不会发生不同文件冲突的可能,用用户上传的文件名保存文件名,这样用户查看、下载文件的时候,文件名更灵活
string key = $"{today.Year}/{today.Month}/{today.Day}/{hash}/{fileName}";
2)检查文件是否上传过,没有上传过则分别上传至两台服务器(每次上传后让流的指针位置归零)。最后将此文件记录返回(在领域服务并不真正保存到数据库,最终由应用服务真正插入到数据库中)
上传文件的逻辑,注意,这里又把真正的上传文件方法提取出来,交给IStorageClient接口的实现类。
public class FSDomainService
{
//注入仓储接口、备份服务存储对象、云存储服务对象
private readonly IFSRepository repository;
private readonly IStorageClient backupStorage;//备份服务器
private readonly IStorageClient remoteStorage;//文件存储服务器
public FSDomainService(IFSRepository repository,
IEnumerable<IStorageClient> storageClients)
{
this.repository = repository;
//用这种方式可以解决内置DI不能使用名字注入不同实例的问题,而且从原则上来讲更加优美
this.backupStorage = storageClients.First(c => c.StorageType == StorageType.Backup);
this.remoteStorage = storageClients.First(c => c.StorageType == StorageType.Public);
}
//领域服务只有抽象的业务逻辑
//UploadAsync负责将文件流保存到两台存储服务器上
public async Task<UploadedItem> UploadAsync(Stream stream, string fileName,
CancellationToken cancellationToken)
{
string hash = HashHelper.ComputeSha256Hash(stream);
long fileSize = stream.Length;
DateTime today = DateTime.Today;
//用日期把文件分散在不同文件夹存储,同时由于加上了文件hash值作为目录,又用用户上传的文件夹做文件名,
//所以几乎不会发生不同文件冲突的可能
//用用户上传的文件名保存文件名,这样用户查看、下载文件的时候,文件名更灵活
string key = $"{today.Year}/{today.Month}/{today.Day}/{hash}/{fileName}";
//查询是否有和上传文件的大小和SHA256一样的文件,如果有的话,就认为是同一个文件
//虽然说前端可能已经调用FileExists接口检查过了,但是前端可能跳过了,或者有并发上传等问题,所以这里再检查一遍。
var oldUploadItem = await repository.FindFileAsync(fileSize, hash);
if (oldUploadItem != null)
{
return oldUploadItem;
}
//backupStorage实现很稳定、速度很快,一般都使用本地存储(文件共享或者NAS)
Uri backupUrl = await backupStorage.SaveAsync(key, stream, cancellationToken);//保存到本地备份
stream.Position = 0;
Uri remoteUrl = await remoteStorage.SaveAsync(key, stream, cancellationToken);//保存到生产的存储系统
stream.Position = 0;
Guid id = Guid.NewGuid();
//
//
return UploadedItem.Create(id, fileSize, fileName, hash, backupUrl, remoteUrl);
}
}
定义一个仓储接口IFSRepository
仓储接口:判断文件是否存在
为应用层判断文件是否存在的接口服务
public interface IFSRepository
{
/// <summary>
/// 查找已经上传的相同大小以及散列值的文件记录
/// </summary>
/// <param name="fileSize"></param>
/// <param name="sha256Hash"></param>
/// <returns></returns>
Task<UploadedItem?> FindFileAsync(long fileSize, string sha256Hash);
}
用户上传的文件会进一步的被保存到备份服务器以及云存储服务器,因为不同的存储服务器的实现差别比较大,而且我们也可能会切换使用不同的存储服务器,为了屏蔽这些存储服务器的差异,我们定义防腐层接口IStorageClient。
防腐层:真正负责上传文件的接口
SaveAsync方法用来把content参数所代表的文件内容保存到存储服务器中,key参数的值一般为文件在服务器端的保存路径,SaveAsync的返回值为保存的文件的全路径
public interface IStorageClient
{
StorageType StorageType { get; }
/// <summary>
/// 保存文件
/// </summary>
/// <param name="key">文件的key(一般是文件路径的一部分)</param>
/// <param name="content">文件内容</param>
/// <returns>存储返回的可以被访问的文件Url</returns>
Task<Uri> SaveAsync(string key, Stream content, CancellationToken cancellationToken = default);
}
IStorageClient的StorageType属性是一个枚举类型,用来表示存储服务器的类型,有Public、Backup两个可选值,分别代表供公众访问的存储服务器和供内网备份用的存储服务器
//存储类型,是供公众访问的存储设备还是内网备份用的存储设备
public enum StorageType
{
Public,//供公众访问的存储设备
Backup//内网备份用的存储设备
}
基础设施层
1、UploadedItem的实体配置
实体配置
1、Guid类型的主键有很多的优点,但是也有聚集索引造成的数据插入时候的性能问题,在SQLServer中我们取消Id主键的聚集索引。
2、由于我们每次上传都要按照文件的大小和散列值来查找历史记录,因此我们设置FileSHA256Hash和FileSizeInBytes组成复合索引。
class UploadedItemConfig : IEntityTypeConfiguration<UploadedItem>
{
public void Configure(EntityTypeBuilder<UploadedItem> builder)
{
builder.ToTable("T_FS_UploadedItems");
builder.HasKey(e => e.Id).IsClustered(false);
builder.Property(e => e.FileName).IsUnicode().HasMaxLength(1024);
builder.Property(e => e.FileSHA256Hash).IsUnicode(false).HasMaxLength(64);
builder.HasIndex(e => new { e.FileSHA256Hash, e.FileSizeInBytes });//
}
}
2、防腐层的存储文件的实现类
本地备份服务器
SMBStorageClient通过Windows共享文件夹来访问备份存储服务器。为了方便部署演示,读者可以把本地磁盘来作为SMBStorageClient的目标文件夹,但是在生产环境下,一定要配备专门的存储服务器。
//用局域网内共享文件夹(NAS等)或者本机磁盘当备份服务器的实现类
class SMBStorageClient : IStorageClient
{
private IOptionsSnapshot<SMBStorageOptions> options;
public SMBStorageClient(IOptionsSnapshot<SMBStorageOptions> options)
{
this.options = options;
}
public StorageType StorageType => StorageType.Backup;
public async Task<Uri> SaveAsync(string key, Stream content, CancellationToken cancellationToken = default)
{
if (key.StartsWith('/'))
{
throw new ArgumentException("key should not start with /", nameof(key));
}
string workingDir = options.Value.WorkingDir;
string fullPath = Path.Combine(workingDir, key);
string? fullDir = Path.GetDirectoryName(fullPath);//get the directory
if (!Directory.Exists(fullDir))//automatically create dir
{
Directory.CreateDirectory(fullDir);
}
if (File.Exists(fullPath))//如果已经存在,则尝试删除
{
File.Delete(fullPath);
}
using Stream outStream = File.OpenWrite(fullPath);
await content.CopyToAsync(outStream, cancellationToken);
return new Uri(fullPath);
}
}
根目录保存在配置类中
public class SMBStorageOptions
{
/// <summary>
/// 根目录
/// </summary>
public string WorkingDir { get; set; }
}
云存储服务器
UpYunStorageClient是适配“又拍云”这个云存储厂商的IStorageClient接口实现类。又拍云存储服务
//第三方包
UpYun.NETCore
class UpYunStorageClient : IStorageClient
{
private IOptionsSnapshot<UpYunStorageOptions> options;
private IHttpClientFactory httpClientFactory;
public UpYunStorageClient(IOptionsSnapshot<UpYunStorageOptions> options,
IHttpClientFactory httpClientFactory)
{
this.options = options;
this.httpClientFactory = httpClientFactory;
}
public StorageType StorageType => StorageType.Public;
static string ConcatUrl(params string[] segments)
{
for (int i = 0; i < segments.Length; i++)
{
string s = segments[i];
if (s.Contains(".."))
{
throw new ArgumentException("路径中不能含有..");
}
segments[i] = s.Trim('/');//把开头结尾的/去掉
}
return string.Join('/', segments);
}
public async Task<Uri> SaveAsync(string key, Stream content, CancellationToken cancellationToken = default)
{
if (key.StartsWith('/'))
{
throw new ArgumentException("key should not start with /", nameof(key));
}
byte[] bytes = content.ToArray();
if (bytes.Length <= 0)
{
throw new ArgumentException("file cannot be empty", nameof(content));
}
string bucketName = options.Value.BucketName;
string userName = options.Value.UserName;
string password = options.Value.Password;
string pathRoot = options.Value.WorkingDir;
string url = ConcatUrl(options.Value.UrlRoot, pathRoot, key);//web访问的文件网址
string fullPath = "/" + ConcatUrl(pathRoot, key);//又拍云的上传路径
UpYunClient upyun = new UpYunClient(bucketName, userName, password, httpClientFactory);
var upyunResult = await upyun.WriteFileAsync(fullPath, bytes, true, cancellationToken);
if (upyunResult.IsOK == false)
{
throw new HttpRequestException("uploading to upyun failed:" + upyunResult.Msg);
}
return new Uri(url);
}
}
配置单独提取到配置类中
public class UpYunStorageOptions
{
public string BucketName { get; set; }
public string UserName { get; set; }
public string Password { get; set; }
/// <summary>
/// 上传的根目录
/// </summary>
public string WorkingDir { get; set; }
/// <summary>
/// http(s)://等这样开头的Url的根目录
/// </summary>
public string UrlRoot { get; set; }
}
模拟云存储服务器
为了方便读者体验运行这个项目并且不把技术实现锁定到某一家云存储服务供应商,我们开发了一个供开发、演示阶段使用的IStorageClient接口实现类MockCloudStorageClient,它会把文件服务器当成一个云存储服务器。这仅供开发、演示阶段使用。
把FileService.WebAPI当成一个云存储服务器,是一个Mock。文件保存在wwwroot文件夹下。这仅供开发、演示阶段使用,在生产环境中,一定要用专门的云存储服务器来代替。
class MockCloudStorageClient : IStorageClient
{
public StorageType StorageType => StorageType.Public;
//注入IWebHostEnvironment,获取运行环境
private readonly IWebHostEnvironment hostEnv;
//注入IHttpContextAccessor,获取当前HttpContext的信息
private readonly IHttpContextAccessor httpContextAccessor;
public MockCloudStorageClient(IWebHostEnvironment hostEnv, IHttpContextAccessor httpContextAccessor)
{
this.hostEnv = hostEnv;
this.httpContextAccessor = httpContextAccessor;
}
public async Task<Uri> SaveAsync(string key, Stream content, CancellationToken cancellationToken = default)
{
if (key.StartsWith('/'))
{
throw new ArgumentException("key should not start with /", nameof(key));
}
string workingDir = Path.Combine(hostEnv.ContentRootPath, "wwwroot");
string fullPath = Path.Combine(workingDir, key);
string? fullDir = Path.GetDirectoryName(fullPath);//get the directory
if (!Directory.Exists(fullDir))//automatically create dir
{
Directory.CreateDirectory(fullDir);
}
if (File.Exists(fullPath))//如果已经存在,则尝试删除
{
File.Delete(fullPath);
}
using Stream outStream = File.OpenWrite(fullPath);
await content.CopyToAsync(outStream, cancellationToken);
var req = httpContextAccessor.HttpContext.Request;
string url = req.Scheme + "://" + req.Host + "/FileService/" + key;
return new Uri(url);
}
}
3、仓储接口的实现类
仓储接口实现
编写IFSRepository的仓储实现类FSRepository
FindFileAsync:查询文件大小、散列值一样的文件记录
class FSRepository : IFSRepository
{
private readonly FSDbContext dbContext;
public FSRepository(FSDbContext dbContext)
{
this.dbContext = dbContext;
}
public Task<UploadedItem?> FindFileAsync(long fileSize, string sha256Hash)
{
return dbContext.UploadItems.FirstOrDefaultAsync(u => u.FileSizeInBytes == fileSize
&& u.FileSHA256Hash == sha256Hash);
}
}
4、在项目的ModuleInitializer中把基础设施中的服务注册到依赖注入容器中
class ModuleInitializer : IModuleInitializer
{
public void Initialize(IServiceCollection services)
{
services.AddHttpContextAccessor();
services.AddScoped<IStorageClient, SMBStorageClient>();
//services.AddScoped<IStorageClient, UpYunStorageClient>();
services.AddScoped<IStorageClient, MockCloudStorageClient>();
services.AddScoped<IFSRepository, FSRepository>();
services.AddScoped<FSDomainService>();
services.AddHttpClient();
}
}
5、DbContext
public class FSDbContext : BaseDbContext
{
public DbSet<UploadedItem> UploadItems { get; private set; }
public FSDbContext(DbContextOptions<FSDbContext> options, IMediator mediator)
: base(options, mediator)
{
}
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
base.OnModelCreating(modelBuilder);
modelBuilder.ApplyConfigurationsFromAssembly(this.GetType().Assembly);
}
}
SDK
把接口封装成SDK的方法可以学一下
FileService.SDK.NETCore项目是把FileService.WebAPI中的HTTP接口封装为一个供其他.NET程序调用的SDK。
作用:不仅是客户端访问,其他微服务也可能访问此微服务来实现文件上传。否则别的微服务实现者需要自己拼请求
提供FileExistsAsync、UploadAsync方法供调用
注意,请求http接口需要提供token,所以提供一个简单的BuildToken方法
//把判断文件是否存在、文件上传的接口封装起来,调用webapi中的方法
public class FileServiceClient
{
private readonly IHttpClientFactory httpClientFactory;
private readonly Uri serverRoot;
private readonly JWTOptions optionsSnapshot;
private readonly ITokenService tokenService;
public FileServiceClient(IHttpClientFactory httpClientFactory, Uri serverRoot, JWTOptions optionsSnapshot, ITokenService tokenService)
{
this.httpClientFactory = httpClientFactory;
this.serverRoot = serverRoot;
this.optionsSnapshot = optionsSnapshot;
this.tokenService = tokenService;
}
public Task<FileExistsResponse> FileExistsAsync(long fileSize, string sha256Hash, CancellationToken stoppingToken = default)
{
string relativeUrl = FormattableStringHelper.BuildUrl($"api/Uploader/FileExists?fileSize={fileSize}&sha256Hash={sha256Hash}");
Uri requestUri = new Uri(serverRoot, relativeUrl);
var httpClient = httpClientFactory.CreateClient();
return httpClient.GetJsonAsync<FileExistsResponse>(requestUri, stoppingToken)!;
}
string BuildToken()
{
//因为JWT的key等机密信息只有服务器端知道,因此可以这样非常简单的读到配置
Claim claim = new Claim(ClaimTypes.Role, "Admin");
return tokenService.BuildToken(new Claim[] { claim }, optionsSnapshot);
}
public async Task<Uri> UploadAsync(FileInfo file, CancellationToken stoppingToken = default)
{
string token = BuildToken();
using MultipartFormDataContent content = new MultipartFormDataContent();
using var fileContent = new StreamContent(file.OpenRead());
content.Add(fileContent, "file", file.Name);
var httpClient = httpClientFactory.CreateClient();
Uri requestUri = new Uri(serverRoot + "/Uploader/Upload");
httpClient.DefaultRequestHeaders.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", token);
var respMsg = await httpClient.PostAsync(requestUri, content, stoppingToken);
if (!respMsg.IsSuccessStatusCode)
{
string respString = await respMsg.Content.ReadAsStringAsync(stoppingToken);
throw new HttpRequestException($"上传失败,状态码:{respMsg.StatusCode},响应报文:{respString}");
}
else
{
string respString = await respMsg.Content.ReadAsStringAsync(stoppingToken);
return respString.ParseJson<Uri>()!;
}
}
}
返回实体
//这个跟应用层的返回实体相同,因为在不同项目,所以重新声明了一遍
public record FileExistsResponse(bool IsExists, Uri? Url);
应用层
FileService.WebAPI项目是文件服务的应用层代码
[Route("[controller]/[action]")]
[ApiController]
[Authorize(Roles = "Admin")]//要求管理员才能访问此页面
[UnitOfWork(typeof(FSDbContext))]//之前讲的只能标注在方法上,这里的又做了扩展,可以标注在控制器类上,里面所有的方法都会自动实现工作单元。
//todo:要做权限控制,这个接口即对内部系统开放、又对前端用户开放。
public class UploaderController : ControllerBase
{
private readonly FSDbContext dbContext;
private readonly FSDomainService domainService;
private readonly IFSRepository repository;
public UploaderController(FSDomainService domainService, FSDbContext dbContext, IFSRepository repository)
{
this.domainService = domainService;
this.dbContext = dbContext;
this.repository = repository;
}
/// <summary>
/// 检查是否有和指定的大小和SHA256完全一样的文件,在客户端计算文件大小、散列值
/// </summary>
/// <param name="request"></param>
/// <returns></returns>
[HttpGet]
public async Task<FileExistsResponse> FileExists(long fileSize, string sha256Hash)
{
var item = await repository.FindFileAsync(fileSize, sha256Hash);
if (item == null)
{
return new FileExistsResponse(false, null);
}
else
{
return new FileExistsResponse(true, item.RemoteUrl);
}
}
//todo: 做好校验,参考OSS的接口,防止被滥用
//todo:应该由应用服务器向fileserver申请一个上传码(可以指定申请的个数,这个接口只能供应用服务器调用),
//页面直传只能使用上传码上传一个文件,防止接口被恶意利用。应用服务器要控制发放上传码的频率。
//todo:再提供一个非页面直传的接口,供服务器用
[HttpPost]
[RequestSizeLimit(60_000_000)]//表示限制请求报文的大小,因为除了文件还会有报文,稍微大一点
//[FromForm]表示来源于表单
public async Task<ActionResult<Uri>> Upload([FromForm] UploadRequest request, CancellationToken cancellationToken = default)
{
var file = request.File;
string fileName = file.FileName;
using Stream stream = file.OpenReadStream();
var upItem = await domainService.UploadAsync(stream, fileName, cancellationToken);
dbContext.Add(upItem);
return upItem.RemoteUrl;
}
}
请求类与返回类
//上传文件的对象,
public class UploadRequest
{
//声明为IFormFile,不要声明为Action的参数,否则不会正常工作
public IFormFile File { get; set; }
}
public class UploadRequestValidator : AbstractValidator<UploadRequest>
{
public UploadRequestValidator()
{
//不用校验文件名的后缀,因为文件服务器会做好安全设置,所以即使用户上传exe、php等文件都是可以的
long maxFileSize = 50 * 1024 * 1024;//最大文件大小
RuleFor(e => e.File).NotNull().Must(f => f.Length > 0 && f.Length < maxFileSize);
}
}
IsExists是否存在这样的文件,如果存在,则Url代表这个文件的路径
//文件是否已经存在的返回结果
public record FileExistsResponse(bool IsExists, Uri? Url);
服务注册、管道模型
var builder = WebApplication.CreateBuilder(args);
//从数据库读取配置
builder.ConfigureDbConfiguration();
//注册其他服务
builder.ConfigureExtraServices(new InitializerOptions
{
LogFilePath = "e:/temp/FileService.log",
EventBusQueueName = "FileService.WebAPI",
});
//从数据库读取又拍云、本地存储路径等的配置信息
builder.Services//.AddOptions() //asp.net core项目中AddOptions()不写也行,因为框架一定自动执行了
.Configure<SMBStorageOptions>(builder.Configuration.GetSection("FileService:SMB"))
.Configure<UpYunStorageOptions>(builder.Configuration.GetSection("FileService:UpYun"));
builder.Services.AddControllers();
builder.Services.AddSwaggerGen(c =>
{
c.SwaggerDoc("v1", new() { Title = "FileService.WebAPI", Version = "v1" });
});
var app = builder.Build();
// Configure the HTTP request pipeline.
if (builder.Environment.IsDevelopment())
{
app.UseDeveloperExceptionPage();
app.UseSwagger();
app.UseSwaggerUI(c => c.SwaggerEndpoint("/swagger/v1/swagger.json", "FileService.WebAPI v1"));
}
//配置静态文件中间件
app.UseStaticFiles();
//启用一系列中间件。因为这是文件服务,可能直接访问静态文件,所以才需要这个
app.UseZackDefault();
app.MapControllers();
app.Run();