文件服务用来将用户上传的音频文件上传到文件服务器和备份服务器。为了提高处理速度和避免文件重复上传,当用户上传服务器中已经存在的文件时,文件服务器会直接把之前的文件返回给上传者。
开发文件服务的领域层
FileService.Domain是文件服务的领域层项目。文件服务中只有一个领域模型“上传项”(UploadedItem),每一次用户上传的文件就是一个“上传项”。UploadedItem实体类中定义了如下几个主要的属性。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60
|
public record Uploadedltem : BaseEntity, IHasCreationTime { public DateTime CreationTime { get; private set; }
public long FileSizeInBytes { get; private set; }
public string FileName { get; private set; }
public string FileSHA256Hash { get; private set; }
public Uri BackupUri { get; private set; }
public Uri RemoteUrl { get; private set; }
public static Uploadedltem Create(Guid id, long fileSizeInBytes, string fileName, string fileSHA256Hash, Uri backupUri, Uri remoteUrl) { Uploadedltem item = new Uploadedltem { Id = id, FileSizeInBytes = fileSizeInBytes, FileName = fileName, FileSHA256Hash = fileSHA256Hash, BackupUri = backupUri, RemoteUrl = remoteUrl, CreationTime = DateTime.Now }; return item; } }
|
继承的类与接口
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40
|
public record BaseEntity : IEntity, IDomainEvents { [NotMapped] private List<INotification> domainEvents = new List<INotification>();
public Guid Id { get; protected set; } = Guid.NewGuid();
public void AddDomainEvent(INotification eventItem) { domainEvents.Add(eventItem); }
public void AddDomainEventIfAbsent(INotification eventItem) { if (!domainEvents.Contains(eventItem)) { domainEvents.Add(eventItem); } }
public void ClearDomainEvents() { domainEvents.Clear(); }
public IEnumerable<INotification> GetDomainEvents() { return domainEvents; } }
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31
|
public interface IDomainEvents { IEnumerable<INotification> GetDomainEvents();
void AddDomainEvent(INotification eventItem);
void AddDomainEventIfAbsent(INotification eventItem);
void ClearDomainEvents(); }
|
用户上传的文件会进一步被保存到备份服务器及云存储服务器,因为不同的存储服务器的实现的差别比较大,而且我们也可能会切换使用不同的存储服务器,所以为了屏蔽这些存储服务器的差异,定义了一个防腐层接口IStorageClient。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19
|
public interface IStorageClient { StorageType StorageType { get; }
Task<Uri> SaveAsync(string key, Stream content, CancellationToken cancellationToken); }
|
IStorageClient和StorageType属性是枚举类型,用来表示存储服务器等类型,有Public、Backup两个可选值,分别代表供公众访问的存储服务器和供内网备份用的存储服务器;
SaveAsync方法用来把content参数所代表的文件内容保存到存储服务器中,key参数的值一般为文件在服务器端的保存路径,SaveAsync的返回值为保存的文件的全路径。
1 2 3 4 5 6 7 8 9 10 11 12 13 14
|
public enum StorageType { Public, Backup, }
|
接下来,定义一个仓储接口IFSRepository.
1 2 3 4 5 6 7 8 9 10
| public interface IFSRepository { Task<UploadedItem?> FindFileAsync(long fileSize, string sha256Hash); }
|
IFSRepository接口的FindFileAsync方法用来查找文件大小为fileSize并且文件的哈希值为sha256Hash的上传记录。
最后,开发文件服务的领域服务FSDomainService.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55
| namespace FileSevice.Domain { 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; this.backupStorage = storageClients.First(c => c.StorageType == StorageType.Backup); this.remoteStorage = storageClients.First(c => c.StorageType == StorageType.Public); }
public async Task<UploadedItem> UploadAsync(Stream stream, string fileName, CancellationToken cancellationToken) { string hash = HashHelper.ComputeSha256Hash(stream); long fileSize = stream.Length; DateTime today = DateTime.Today; string key = $"{today.Year}/{today.Month}/{today.Day}/{hash}/{fileName}"; var oldUploadedItem = await repository.FindFileAsync(fileSize, hash); if (oldUploadedItem != null) { return oldUploadedItem; } 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); } } }
|
其中提供了自定义常用的哈希计算方法
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79
| namespace YU.Commons { public static class HashHelper { private static string ToHashString(byte[] bytes) { StringBuilder builder = new StringBuilder(); for (int i = 0; i < bytes.Length; i++) { builder.Append(bytes[i].ToString("x2")); } return builder.ToString(); }
public static string ComputeSha256Hash(Stream stream) { using (SHA256 sha256Hash = SHA256.Create()) { byte[] bytes = sha256Hash.ComputeHash(stream); return ToHashString(bytes); } }
public static string ComputeSha256Hash(string input) { using (SHA256 sha256Hash = SHA256.Create()) { byte[] bytes = sha256Hash.ComputeHash(Encoding.UTF8.GetBytes(input)); return ToHashString(bytes); } }
public static string ComputeMd5Hash(string input) { using (MD5 md5Hash = MD5.Create()) { byte[] bytes = md5Hash.ComputeHash(Encoding.UTF8.GetBytes(input)); return ToHashString(bytes); } }
public static string ComputeMd5Hash(Stream input) { using (MD5 md5Hash = MD5.Create()) { byte[] bytes = md5Hash.ComputeHash(input); return ToHashString(bytes); } } } }
|
开发文件服务的基础设施层
UploadedItem实体类采用Guid类型的主键,Guid类型的主键有很多优点,但是也有聚集索引造成的数据插入时的性能问题,我们采用的是SQLServer数据库,因此我们需要取消Id主键的聚集索引;由于我们每次上传都要按照文件的大小和哈希值来查找历史记录,因此我们设置FileSHA256Hash和FileSizeBytes组成复合索引。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18
| 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 }); } }
|
使用IStorageClient接口的实现类MockCloudStorageClient,他会把文件服务器当成一个云存储服务器。当然这仅供开发、演示阶段使用,在生产环境中,一定用专门的云存储服务器来代替。