Skip to content
Bitzsoft.Integrationsbitzsoft.integrations

Concept

FileStorage 文件存储

FileStorage 域的胖接口设计哲学、Args 模式、预签名 URL 直传、Singleton 生命周期与 10 家云存储厂商的能力矩阵。

Last updated

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。失败时直接抛标准异常:

  • FileNotFoundExceptionGetFileStreamAsync / 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鉴权区域稳定性
阿里云 OSSaliyunAccessKeyId / AccessKeySecretCN / GlobalPreview
腾讯云 COStencentSecretId / SecretKeyCN / GlobalPreview
华为云 OBShuaweiAK / SKCN / GlobalPreview
Amazon S3awsAccess keyGlobalPreview
Azure Blobazure连接串 / 账户密钥GlobalPreview
MinIOminioAccess keyPrivate / GlobalPreview
七牛云 KodoqiniuAccessKey / SecretKeyCNPreview
又拍云 USSupyun操作员凭证CNPreview
金山云 KS3kingsoftAccess keyCNPreview
NAS 文件系统nas主机文件系统权限PrivatePreview

全部 10 家的 Stability 都是 Preview(来自 FileStorageProviderDescriptors)。桶操作和文件读写基本可用;部分厂商的预签名 URL 过期策略和 ACL 管理的边界场景可能未覆盖。NAS 的 Descriptor limitations 字段明确记录:“必须确保多实例主机挂载同一个共享文件系统。“

阿里云的双注册

阿里云是唯一额外暴露厂商专有接口的厂商。IAliyunOssFileStore : IFileStore 追加一个带存储桶参数、ACL、存储类型、数据冗余的 CreateBucketAsync(CreateBucketArgs)。注册时同一个 AliyunFileStore 实例同时绑定到 IFileStoreIAliyunOssFileStore

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 是复制 + 删除:内部先 CopyFileAsyncDeleteFileAsync,非原子操作;复制失败时源文件保留。
  • NAS 多实例可见性:NAS 厂商要求多实例主机挂载同一个共享文件系统,否则各实例文件互相不可见。

相关

100%

滚轮或按钮缩放 · 放大后拖动画面 · 双击切换 100% / 200%