FileStorage 域把对象存储与本地文件系统统一到一个”胖接口”背后。10 家厂商(阿里云 OSS、腾讯云 COS、华为云 OBS、AWS S3、Azure Blob、MinIO、七牛云 Kodo、又拍云 USS、金山云 KS3、NAS)共享同一个 IFileStore 契约,业务代码不感知底层是哪家云。本页解释这套抽象的设计取舍;精确的成员清单见 FileStorage 抽象参考,阿里云专有扩展见 FileStorage · 阿里云 OSS。
域结构
Bitzsoft.Integrations.FileStorage ← 抽象层(接口 + DTO + 命名处理器)├── Bitzsoft.Integrations.FileStorage.Aliyun ← 阿里云 OSS(额外暴露 IAliyunOssFileStore)├── Bitzsoft.Integrations.FileStorage.Tencent ← 腾讯云 COS├── Bitzsoft.Integrations.FileStorage.Huawei ← 华为云 OBS├── Bitzsoft.Integrations.FileStorage.Aws ← Amazon S3├── Bitzsoft.Integrations.FileStorage.Azure ← Azure Blob├── Bitzsoft.Integrations.FileStorage.Minio ← MinIO(S3 兼容)├── Bitzsoft.Integrations.FileStorage.Qiniu ← 七牛云 Kodo├── Bitzsoft.Integrations.FileStorage.Upyun ← 又拍云 USS├── Bitzsoft.Integrations.FileStorage.Kingsoft ← 金山云 KS3├── Bitzsoft.Integrations.FileStorage.Nas ← 本地 / 共享文件系统└── Bitzsoft.Integrations.FileStorage.All ← 聚合包(按配置节按需注册)标准三层:抽象层定义接口与共享类型,每家厂商一个实现包,All 聚合包按配置节存在性按需注册。
胖接口设计
IFileStore 是一个胖接口(fat interface)。它把桶操作、文件 CRUD、复制移动、预签名 URL 都放进同一个契约,共 21 个成员方法(接口本身只声明方法,无 ProviderName 属性)。
public interface IFileStore{ // ① 桶操作(4) Task<List<string>> GetBucketNamesAsync(CancellationToken ct = default); Task<bool> BucketExistsAsync(string bucketName, string? policy = null, CancellationToken ct = default); Task CreateBucketAsync(string bucketName, string? policy = null, CancellationToken ct = default); Task DeleteBucketAsync(string bucketName, string? policy = null, CancellationToken ct = default);
// ② 文件存在性(2 重载) Task<bool> FileExistsAsync(string fileName, string? policy = null, CancellationToken ct = default); Task<bool> FileExistsAsync(FileExistsArgs args, CancellationToken ct = default);
// ③ 文件读取(2 重载) Task<Stream?> GetFileStreamAsync(string fileName, string? policy = null, CancellationToken ct = default); Task<Stream?> GetFileStreamAsync(GetFileStreamArgs args, CancellationToken ct = default);
// ④ 文件写入(简易 + Args 各 2 重载) Task<FileResult> SaveFileAsync(Stream stream, string fileName, string? policy = null, CancellationToken ct = default); Task<FileResult> SaveFileAsync(SaveFileArgs args, CancellationToken ct = default); Task<FileResult> SaveFileByUrlAsync(string url, string fileName, string? policy = null, CancellationToken ct = default); Task<FileResult> SaveFileByUrlAsync(SaveFileByUrlArgs args, CancellationToken ct = default);
// ⑤ 复制 / 移动 / 删除 Task CopyFileAsync(string sourceFileName, string destinationFileName, CancellationToken ct = default); Task CopyFileAsync(FileStorageArgs sourceArgs, FileStorageArgs destinationArgs, CancellationToken ct = default); Task MoveFileAsync(string sourceFileName, string destinationFileName, CancellationToken ct = default); Task MoveFileAsync(FileStorageArgs sourceArgs, FileStorageArgs destinationArgs, CancellationToken ct = default); Task DeleteFileAsync(string fileName, string? policy = null, CancellationToken ct = default); Task DeleteFileAsync(DeleteFileArgs args, CancellationToken ct = default);
// ⑥ 预签名 URL(客户端直传直下) Task<string> GenerateDownloadUrlAsync(string fileName, string? policy = null, CancellationToken ct = default); Task<string> GenerateDownloadUrlAsync(GenerateDownloadUrlArgs args, CancellationToken ct = default); Task<DirectUploadParam> GenerateUploadUrlAsync(string fileName, string? policy = null, CancellationToken ct = default); Task<DirectUploadParam> GenerateUploadUrlAsync(GenerateUploadUrlArgs args, CancellationToken ct = default);
// ⑦ 清空 Task ClearAsync(CancellationToken ct = default);}为什么选胖接口而不是像 TeamWork 那样按能力拆 15 个细粒度接口?关键在于能力差异的方向不同:
- TeamWork 域里钉钉有日历、飞书没有客户联系人,厂商之间是横向差异——每家实现不同子集,拆接口才能让契约精准。
- FileStorage 域里 10 家厂商的操作集合几乎完全一致:每家都能建桶、存文件、生成签名 URL。差异只在底层 SDK 协议(OSS / S3 / Blob REST),是纵向差异。把同一组操作拆成
IBucketStore/IFileWriter/IPresignedUrlStore只会让调用方为了完成一次”存文件”而注入三个接口,收益为零。
所以这里刻意把所有文件操作收拢进一个 IFileStore,让”存文件、读文件、签名下载”成为一次注入就能完成的连贯动作。阿里云额外多出的 CreateBucketAsync(CreateBucketArgs)(带 ACL / 存储类型)是唯一例外,通过派生接口 IAliyunOssFileStore 暴露,不污染通用契约。
Args 模式:简易重载与强类型重载
多数文件操作提供两套重载。简易重载直接传文件名和策略字符串;强类型重载接收 Args 对象:
// 简易重载:只关心文件名和命名策略await fileStore.SaveFileAsync(stream, "report.pdf", policy: "date-prefix");
// 强类型重载:需要指定 ContentType、ContentDisposition、跨桶目标var args = new SaveFileArgs("report.pdf", stream){ ContentType = "application/pdf", ContentDisposition = """attachment; filename="report.pdf" """, BucketName = "archive-prod", BucketNamePolicy = "lowercase"};await fileStore.SaveFileAsync(args);两套重载不是冗余,而是演化契约的两个出口:
| 场景 | 用哪个 | 原因 |
|---|---|---|
| 只需要一个文件名 | 简易重载 | 调用点最短,桶名走 Options 默认值 |
| 需要跨桶、带元数据 | Args 重载 | 字段显式,未来新增字段不破坏简易重载调用方 |
| 新增能力(如 ContentType) | 扩展 Args | 简易重载签名不变,二进制兼容 |
policy:文件名与桶名处理策略
简易重载里的 policy 字符串不是路径规则,而是命名处理策略标识。它交给两个工厂决定如何规整名称:
IFileNameProcessorFactory.CreateProcessor(policy)→ 处理文件名(去特殊字符、加日期前缀、统一小写等)IBucketNameProcessorFactory.CreateProcessor(policy)→ 处理桶名(满足各家命名合规要求,如全小写、长度限制)
"report.PDF" ──policy="date-prefix"──▶ IFileNameProcessor ──▶ "20260801/report.pdf""MY_Bucket" ──policy="lowercase"────▶ IBucketNameProcessor ──▶ "my-bucket"两层处理把命名规则从业务代码里剥离。处理后的结果封装成 ProcessedName,内部包含原始名、处理后名等元信息,由各厂商实现消费。
FileSize 值类型
文件大小用 readonly struct FileSize 表达,而不是裸 long。它把字节长度和单位换算收进一个类型:
var size = new FileSize(1536); // 默认字节Console.WriteLine(size.Size); // 1536(字节)Console.WriteLine(size.GetSizeByK()); // 1.5(KB,保留两位小数)Console.WriteLine(size.GetSizeByM()); // 0.0(MB)Console.WriteLine(size.ToString()); // "1.5 KB"
var gb = new FileSize(2, FileSizeUnit.G); // 以 GB 构造,内部换算成字节FileSizeUnit 枚举对应 B / KB / MB / GB(带 [Description])。FileResult.Size 字段就是 FileSize 类型,ToString() 会按数量级自动选择最合适的单位输出。
预签名 URL:客户端直传直下
对象存储最典型的两阶段模式是服务端签名、客户端直传。IFileStore 用两个方法支撑:
GenerateUploadUrlAsync→ 返回DirectUploadParam{ Url, Data }。浏览器/移动端把文件直接 PUT/POST 到Url,附带Data(表单字段或签名)。GenerateDownloadUrlAsync→ 返回一条带签名的下载链接,客户端用它直接拉取私有桶里的文件。
// 服务端生成直传参数,前端拿到后自行上传,文件不经过应用服务器var upload = await fileStore.GenerateUploadUrlAsync("video.mp4");// upload.Url → "https://bucket.oss-cn-hangzhou.aliyuncs.com"// upload.Data → 厂商签名表单(如阿里云 DirectUploadData)
// 私有文件的临时下载链接,过期失效var download = await fileStore.GenerateDownloadUrlAsync("report.pdf");上传 URL 的有效期由 Options 配置(阿里云 UploadUrlExpiration 默认 3600 秒),下载 URL 同理(DownloadUrlExpiration)。GenerateUploadUrlArgs 还提供 SetContentType / SetOctetStream / AddHeader,用于在签名阶段约束客户端上传时的请求头。
错误处理:抛标准异常
FileStorage 域没有 Result<T> 包装,也没有自定义的 FileStorageException。失败时直接抛标准异常:
FileNotFoundException:GetFileStreamAsync/GenerateDownloadUrlAsync在文件不存在时抛出ArgumentNullException:参数为空(如FileResult构造时filePath为空)InvalidOperationException:上传返回非 200 时抛出- 各厂商 SDK 自身的异常(如阿里云
OssException)
这意味着调用方要么 try/catch,要么让异常冒泡到统一异常过滤器。GetFileStreamAsync 的返回类型是 Stream?,但阿里云实现在文件不存在时仍抛 FileNotFoundException(与 XML 注释声明的”返回 null”行为不一致,调用方应同时处理两种情况)。DeleteFileAsync / DeleteBucketAsync 对不存在的目标幂等——文件或桶不存在时静默返回。
生命周期:Singleton
FileStorage 域的 Provider 注册为 Singleton,和其他域(TeamWork / Express 多为 Transient)不同:
services.AddIntegrationProviderCapability<IFileStore, AliyunFileStore>( FileStorageProviderDescriptors.Aliyun, ServiceLifetime.Singleton); // 显式声明 Singleton理由是底层对象存储客户端(阿里云 OssClient、AWS SDK、MinIO client 等)本身线程安全,但构造开销不低(连接池、TLS 握手)。缓存为单例可避免每次请求重建客户端,拿到明显的吞吐收益。AliyunFileStore 内部用延迟初始化(_client != null 双检)缓存 IOss 实例。
厂商能力矩阵
| 厂商 | Provider ID | 鉴权 | 区域 | 稳定性 |
|---|---|---|---|---|
| 阿里云 OSS | aliyun | AccessKeyId / AccessKeySecret | CN / Global | Preview |
| 腾讯云 COS | tencent | SecretId / SecretKey | CN / Global | Preview |
| 华为云 OBS | huawei | AK / SK | CN / Global | Preview |
| Amazon S3 | aws | Access key | Global | Preview |
| Azure Blob | azure | 连接串 / 账户密钥 | Global | Preview |
| MinIO | minio | Access key | Private / Global | Preview |
| 七牛云 Kodo | qiniu | AccessKey / SecretKey | CN | Preview |
| 又拍云 USS | upyun | 操作员凭证 | CN | Preview |
| 金山云 KS3 | kingsoft | Access key | CN | Preview |
| NAS 文件系统 | nas | 主机文件系统权限 | Private | Preview |
全部 10 家的 Stability 都是 Preview(来自 FileStorageProviderDescriptors)。桶操作和文件读写基本可用;部分厂商的预签名 URL 过期策略和 ACL 管理的边界场景可能未覆盖。NAS 的 Descriptor limitations 字段明确记录:“必须确保多实例主机挂载同一个共享文件系统。“
阿里云的双注册
阿里云是唯一额外暴露厂商专有接口的厂商。IAliyunOssFileStore : IFileStore 追加一个带存储桶参数、ACL、存储类型、数据冗余的 CreateBucketAsync(CreateBucketArgs)。注册时同一个 AliyunFileStore 实例同时绑定到 IFileStore 和 IAliyunOssFileStore:
services.AddIntegrationProviderCapability<IFileStore, AliyunFileStore>( FileStorageProviderDescriptors.Aliyun, ServiceLifetime.Singleton);services.AddSingleton<IAliyunOssFileStore>( sp => sp.GetRequiredService<AliyunFileStore>());需要阿里云专有能力时注入 IAliyunOssFileStore,通用逻辑注入 IFileStore,两者指向同一实例。详见 FileStorage · 阿里云 OSS。
注册模式
单厂商
// ① 阿里云 OSS(委托式配置,内部走 Validate + ValidateOnIntegrationStart)builder.Services.AddBitzsoftAliyunOssFileStorage(options =>{ options.Endpoint = "oss-cn-hangzhou.aliyuncs.com"; options.AccessKeyId = "your-access-key-id"; options.AccessKeySecret = "your-access-key-secret"; options.DefaultBucketName = "my-bucket";});
// ② MinIO(配置节绑定式)builder.Services.AddBitzsoftMinioFileStorage(builder.Configuration.GetSection("FileStorage:Minio"));多厂商聚合
builder.Services.AddBitzsoftFileStorageAll(builder.Configuration, "FileStorage");All 聚合包按配置节存在性按需注册——配置里写了哪几家就注册哪几家,没写的不占容器位置:
{ "FileStorage": { "Aliyun": { "Endpoint": "...", "AccessKeyId": "...", "AccessKeySecret": "...", "DefaultBucketName": "..." }, "Minio": { "Endpoint": "...", "AccessKey": "...", "SecretKey": "...", "DefaultBucketName": "..." } }}消费
public class DocumentService(IFileStore fileStore){ public async Task<string> SaveContractAsync(Stream content, string fileName) { var result = await fileStore.SaveFileAsync(content, fileName); // ① 落盘 return result.FileName; // ② 返回规整后的文件名 }
public async Task<string> GetDownloadLinkAsync(string fileName) { // ③ 生成一小时内有效的下载 URL,交给客户端直下 return await fileStore.GenerateDownloadUrlAsync(fileName); }}多厂商路由用 IIntegrationProviderResolver<IFileStore>:
public class StorageRouter(IIntegrationProviderResolver<IFileStore> stores){ public async Task UploadAsync(string providerId, Stream content, string fileName) { var store = stores.GetRequired(providerId); // ① 按 "aliyun" / "minio" 解析 await store.SaveFileAsync(content, fileName); }}IFileStoreExtensions 还提供了三个扩展方法,简化从本地文件、字节数组存入与取回字节的场景:SaveFileAsync(FileInfo)、SaveFileAsync(byte[])、GetFileBytesAsync(fileName)(后者在文件不存在时抛 FileNotFoundException)。
已知问题与边界
SaveFileByUrlAsync走 SSRF 防护:内部用IRemoteFileFetcher(默认实现SecureRemoteFileFetcher)下载远程文件,会校验 URL scheme 并限制内网地址,避免服务端请求伪造。GetFileStreamAsync返回类型与实现行为:契约声明返回null表示文件不存在,但阿里云实现实际抛FileNotFoundException。编写跨厂商代码时应同时处理null和异常。MoveFileAsync是复制 + 删除:内部先CopyFileAsync再DeleteFileAsync,非原子操作;复制失败时源文件保留。- NAS 多实例可见性:NAS 厂商要求多实例主机挂载同一个共享文件系统,否则各实例文件互相不可见。
相关
- FileStorage 抽象参考:全部 21 个方法的精确签名与 DTO 字段表
- FileStorage · 阿里云 OSS:阿里云专有扩展与 STS 直传
- 三层包结构
- 结构变体
- Provider 目录