文件服务用来将用户上传的音频文件上传到文件服务器和备份服务器。为了提高处理速度和避免文件重复上传,当用户上传服务器中已经存在的文件时,文件服务器会直接把之前的文件返回给上传者。

开发文件服务的领域层

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
/// <summary>
/// 表示已上传的文件项,包含文件的基本信息和备份、远程访问地址。
/// </summary>
public record Uploadedltem : BaseEntity, IHasCreationTime
{
/// <summary>
/// 文件上传的时间。
/// </summary>
public DateTime CreationTime { get; private set; }

/// <summary>
/// 文件的大小(字节数)。
/// </summary>
public long FileSizeInBytes { get; private set; }

/// <summary>
/// 文件名。
/// </summary>
public string FileName { get; private set; }

/// <summary>
/// 文件的SHA256哈希值,用于校验文件完整性。
/// </summary>
public string FileSHA256Hash { get; private set; }

/// <summary>
/// 文件的备份地址(URI)。
/// </summary>
public Uri BackupUri { get; private set; }

/// <summary>
/// 文件的远程访问地址(URI)。
/// </summary>
public Uri RemoteUrl { get; private set; }

/// <summary>
/// 创建一个新的已上传文件项。
/// </summary>
/// <param name="id">文件项的唯一标识。</param>
/// <param name="fileSizeInBytes">文件大小(字节)。</param>
/// <param name="fileName">文件名。</param>
/// <param name="fileSHA256Hash">文件SHA256哈希值。</param>
/// <param name="backupUri">备份地址。</param>
/// <param name="remoteUrl">远程访问地址。</param>
/// <returns>新创建的 <see cref="Uploadedltem"/> 实例。</returns>
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
// BaseEntity 是所有领域实体的基础类,包含领域事件的管理功能。
// 领域事件用于在业务发生变化时通知其他部分进行响应。
public record BaseEntity : IEntity, IDomainEvents
{
// domainEvents 用于存储当前实体产生的所有领域事件。
// [NotMapped] 表示该字段不会被映射到数据库表中。
[NotMapped]
private List<INotification> domainEvents = new List<INotification>();

// Id 是实体的唯一标识,每个实体创建时自动生成一个新的 Guid。
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
/// <summary>
/// 定义领域事件集合的接口。
/// 该接口用于聚合根或实体中管理领域事件的添加、去重、获取和清理操作。
/// 领域事件用于在领域模型内部或外部传播重要的业务状态变化。
/// </summary>
public interface IDomainEvents
{
/// <summary>
/// 获取当前已注册的所有领域事件。
/// </summary>
/// <returns>领域事件的只读集合。</returns>
IEnumerable<INotification> GetDomainEvents();

/// <summary>
/// 添加一个新的领域事件到集合中。
/// </summary>
/// <param name="eventItem">要添加的领域事件。</param>
void AddDomainEvent(INotification eventItem);

/// <summary>
/// 如果集合中不存在该事件,则添加一个新的领域事件。
/// 用于避免重复添加相同的事件。
/// </summary>
/// <param name="eventItem">要添加的领域事件。</param>
void AddDomainEventIfAbsent(INotification eventItem);

/// <summary>
/// 清空当前所有已注册的领域事件。
/// </summary>
void ClearDomainEvents();
}

用户上传的文件会进一步被保存到备份服务器及云存储服务器,因为不同的存储服务器的实现的差别比较大,而且我们也可能会切换使用不同的存储服务器,所以为了屏蔽这些存储服务器的差异,定义了一个防腐层接口IStorageClient。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
/// <summary>
/// 存储客户端接口,定义存储类型及保存文件的方法。
/// </summary>
public interface IStorageClient
{
/// <summary>
/// 获取存储类型。
/// </summary>
StorageType StorageType { get; }

/// <summary>
/// 异步保存文件内容到指定存储。
/// </summary>
/// <param name="key">文件的唯一标识键。</param>
/// <param name="content">文件内容流。</param>
/// <param name="cancellationToken">取消操作的令牌。</param>
/// <returns>返回文件的访问 Uri。</returns>
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
/// <summary>
/// 存储类型枚举,定义文件存储的类别。
/// </summary>
public enum StorageType
{
/// <summary>
/// 公共存储类型,通常用于普通文件访问。
/// </summary>
Public,
/// <summary>
/// 备份存储类型,通常用于文件备份。
/// </summary>
Backup,
}

接下来,定义一个仓储接口IFSRepository.

1
2
3
4
5
6
7
8
9
10
public interface IFSRepository
{
/// <summary>
/// 查找已经上传的相同大小以及散列值的文件记录
/// </summary>
/// <param name="fileSize"></param>
/// <param name="sha256Hash"></param>
/// <returns></returns>
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)
{
// 计算文件流的SHA256哈希值
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
{
/// <summary>
/// 提供常用的哈希计算方法(SHA256、MD5)。
/// </summary>
public static class HashHelper
{
/// <summary>
/// 将字节数组转换为十六进制字符串。
/// </summary>
/// <param name="bytes">要转换的字节数组。</param>
/// <returns>十六进制字符串。</returns>
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();
}

/// <summary>
/// 计算流的 SHA256 哈希值。
/// </summary>
/// <param name="stream">输入流。</param>
/// <returns>SHA256 哈希字符串。</returns>
public static string ComputeSha256Hash(Stream stream)
{
using (SHA256 sha256Hash = SHA256.Create())
{
byte[] bytes = sha256Hash.ComputeHash(stream);
return ToHashString(bytes);
}
}

/// <summary>
/// 计算字符串的 SHA256 哈希值。
/// </summary>
/// <param name="input">输入字符串。</param>
/// <returns>SHA256 哈希字符串。</returns>
public static string ComputeSha256Hash(string input)
{
using (SHA256 sha256Hash = SHA256.Create())
{
byte[] bytes = sha256Hash.ComputeHash(Encoding.UTF8.GetBytes(input));
return ToHashString(bytes);
}
}

/// <summary>
/// 计算字符串的 MD5 哈希值。
/// </summary>
/// <param name="input">输入字符串。</param>
/// <returns>MD5 哈希字符串。</returns>
public static string ComputeMd5Hash(string input)
{
using (MD5 md5Hash = MD5.Create())
{
byte[] bytes = md5Hash.ComputeHash(Encoding.UTF8.GetBytes(input));
return ToHashString(bytes);
}
}

/// <summary>
/// 计算流的 MD5 哈希值。
/// </summary>
/// <param name="input">输入流。</param>
/// <returns>MD5 哈希字符串。</returns>
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
// UploadedItemConfig 用于配置 UploadedItem 实体在数据库中的映射方式。
class UploadedItemConfig : IEntityTypeConfiguration<UploadedItem>
{
// Configure 方法定义了 UploadedItem 实体的具体映射规则。
public void Configure(EntityTypeBuilder<UploadedItem> builder)
{
// 设置表名为 T_FS_UploadedItems。
builder.ToTable("T_FS_UploadedItems");
// 设置主键为 Id,并且不使用聚集索引。
builder.HasKey(e => e.Id).IsClustered(false);
// 配置 FileName 字段为 Unicode 字符串,最大长度 1024。
builder.Property(e => e.FileName).IsUnicode().HasMaxLength(1024);
// 配置 FileSHA256Hash 字段为非 Unicode 字符串,最大长度 64。
builder.Property(e => e.FileSHA256Hash).IsUnicode(false).HasMaxLength(64);
// 为 FileSHA256Hash 和 FileSizeInBytes 创建联合索引,加快查询速度。
builder.HasIndex(e => new { e.FileSHA256Hash, e.FileSizeInBytes });
}
}

使用IStorageClient接口的实现类MockCloudStorageClient,他会把文件服务器当成一个云存储服务器。当然这仅供开发、演示阶段使用,在生产环境中,一定用专门的云存储服务器来代替。