文件存储几乎是每个业务系统都绕不开的功能。项目初期图省事,文件直接写进本地磁盘的某个目录;等业务上了云,运营又要求把附件迁到Azure Blob;再后来公司采购了AWS,S3又成了标配。如果一开始没有做好抽象,业务代码里就会散落着File.ReadAllBytes、BlobClient.UploadAsync、PutObjectRequest这些来自不同SDK的调用,迁移一次就要全项目搜代码改代码。本文就来聊一聊,如何用C#设计一个可插拔的文件存储库,让本地、Azure、S3三种存储在业务层面看起来毫无差别。

一、先定契约:统一文件存储接口的设计
可插拔架构的核心思想是依赖倒置:业务层只依赖抽象,不依赖具体实现。所以在动手写实现类之前,先把所有存储后端都必须遵守的契约定义出来。这个接口不宜设计得过于庞大,聚焦最常用的几个能力即可:上传、下载、删除、判断文件是否存在,再加上获取文件元信息。过多的方法会让新提供程序的实现成本变高,违背可插拔的初衷。
有几个设计细节值得注意。第一,参数和返回值尽量用Stream而不是byte[],这样处理大文件时不必把整个文件加载进内存。第二,路径统一用字符串表示,约定使用正斜杠分隔,由各提供程序内部负责转换成平台相关的格式。第三,把配置信息抽成独立的选项类,通过.NET的IOptions模式注入,避免把连接字符串硬编码在实现里。
public interface IFileStorage
{
// 上传文件,existsWrite 决定同名文件是覆盖还是报错
Task UploadAsync(string path, Stream content, bool overwrite = true, CancellationToken ct = default);
// 下载文件,返回流,由调用方负责释放
Task<Stream> DownloadAsync(string path, CancellationToken ct = default);
// 删除文件,文件不存在时不抛异常,返回是否实际删除
Task<bool> DeleteAsync(string path, CancellationToken ct = default);
Task<bool> ExistsAsync(string path, CancellationToken ct = default);
Task<long> GetSizeAsync(string path, CancellationToken ct = default);
}
// 存储相关配置,不同提供程序关注不同字段
public class FileStorageOptions
{
public string Provider { get; set; } = "Local";
public string RootPath { get; set; }
public string ConnectionString { get; set; }
public string Bucket { get; set; }
}有了这个接口,业务代码就变成了_storage.UploadAsync("orders/2024/inv.pdf", stream)这样的调用,至于文件最终落在哪里,业务层完全不用关心。这正是可插拔的价值所在:切换存储后端只改配置,不改代码。
二、三个提供程序的实现要点
1. 本地存储实现
本地实现最简单,主要工作是路径拼接和安全校验。安全校验不可省略,因为如果调用方传入../../secrets.txt这样的路径,就可能越权读写到根目录之外的文件。常用的做法是把拼接后的绝对路径做一次规范化,再校验它是否仍以根目录开头。
public class LocalFileStorage : IFileStorage
{
private readonly string _root;
public LocalFileStorage(IOptions<FileStorageOptions> options)
{
_root = Path.GetFullPath(options.Value.RootPath);
Directory.CreateDirectory(_root);
}
private string Resolve(string path)
{
var full = Path.GetFullPath(Path.Combine(_root, path.Replace('/', Path.DirectorySeparatorChar)));
if (!full.StartsWith(_root, StringComparison.OrdinalIgnoreCase))
throw new UnauthorizedAccessException($"非法路径: {path}");
return full;
}
public async Task UploadAsync(string path, Stream content, bool overwrite = true, CancellationToken ct = default)
{
var full = Resolve(path);
Directory.CreateDirectory(Path.GetDirectoryName(full)!);
if (!overwrite && File.Exists(full))
throw new IOException($"文件已存在: {path}");
await using var fs = new FileStream(full, overwrite ? FileMode.Create : FileMode.CreateNew,
FileAccess.Write, FileShare.None, 81920, useAsync: true);
await content.CopyToAsync(fs, ct);
}
public Task<Stream> DownloadAsync(string path, CancellationToken ct = default)
{
var full = Resolve(path);
if (!File.Exists(full)) throw new FileNotFoundException(path);
return Task.FromResult<Stream>(new FileStream(full, FileMode.Open, FileAccess.Read,
FileShare.Read, 81920, useAsync: true));
}
public Task<bool> DeleteAsync(string path, CancellationToken ct = default)
{
var full = Resolve(path);
if (!File.Exists(full)) return Task.FromResult(false);
File.Delete(full);
return Task.FromResult(true);
}
public Task<bool> ExistsAsync(string path, CancellationToken ct = default)
=> Task.FromResult(File.Exists(Resolve(path)));
public Task<long> GetSizeAsync(string path, CancellationToken ct = default)
=> Task.FromResult(new FileInfo(Resolve(path)).Length);
}注意FileStream构造时传了useAsync: true和较大的缓冲区,这对异步IO性能有明显帮助,尤其是高并发场景下能减少线程阻塞。
2. Azure Blob 与 S3 实现
云存储实现的关键是复用官方SDK的客户端对象,而不是每次操作都新建客户端。Azure方面用BlobServiceClient,AWS方面用IAmazonS3,两者都是线程安全的,可以在构造函数里创建一次,随单例生命周期使用。下面以S3为例展示核心写法,Azure的套路几乎一致。
public class S3FileStorage : IFileStorage
{
private readonly IAmazonS3 _client;
private readonly string _bucket;
public S3FileStorage(IOptions<FileStorageOptions> options)
{
_bucket = options.Value.Bucket;
_client = new AmazonS3Client(RegionEndpoint.APNortheast1); // 凭证由环境变量或IAM角色提供
}
public async Task UploadAsync(string path, Stream content, bool overwrite = true, CancellationToken ct = default)
{
var request = new PutObjectRequest
{
BucketName = _bucket,
Key = path,
InputStream = content
};
await _client.PutObjectAsync(request, ct);
}
public async Task<Stream> DownloadAsync(string path, CancellationToken ct = default)
{
var resp = await _client.GetObjectAsync(_bucket, path, ct);
return resp.ResponseStream;
}
public async Task<bool> DeleteAsync(string path, CancellationToken ct = default)
{
await _client.DeleteObjectAsync(_bucket, path, ct);
return true;
}
public async Task<bool> ExistsAsync(string path, CancellationToken ct = default)
{
try
{
await _client.GetObjectMetadataAsync(_bucket, path, ct);
return true;
}
catch (AmazonS3Exception ex) when (ex.StatusCode == HttpStatusCode.NotFound)
{
return false;
}
}
public async Task<long> GetSizeAsync(string path, CancellationToken ct = default)
{
var meta = await _client.GetObjectMetadataAsync(_bucket, path, ct);
return meta.ContentLength;
}
}云存储有个隐藏成本要注意:SDK抛出的异常类型五花八门,比如S3的AmazonS3Exception、Azure的RequestFailedException,业务层如果直接捕获这些异常,又回到了耦合SDK的老路。建议在仓储层内部做一层翻译,统一转成自定义的FileStorageException,带上原始异常作为InnerException,方便排查的同时保持契约干净。
三、配置驱动切换与扩展性验证
接口和实现都有了,怎么让系统根据配置自动选择提供程序?答案是工厂加依赖注入。在Program.cs里读取配置节,根据Provider字段决定注册哪一个实现,业务代码注入的永远是IFileStorage。
builder.Services.Configure<FileStorageOptions>(builder.Configuration.GetSection("FileStorage"));
var opt = builder.Configuration.GetSection("FileStorage").Get<FileStorageOptions>();
builder.Services.AddSingleton<IFileStorage>(sp => opt.Provider switch
{
"Local" => new LocalFileStorage(sp.GetRequiredService<IOptions<FileStorageOptions>>()),
"Azure" => new AzureBlobFileStorage(sp.GetRequiredService<IOptions<FileStorageOptions>>()),
"S3" => new S3FileStorage(sp.GetRequiredService<IOptions<FileStorageOptions>>()),
_ => throw new NotSupportedException($"未知提供程序: {opt.Provider}")
});对应的配置文件只需要三行:
<FileStorage> <Provider>S3</Provider> <Bucket>my-app-files</Bucket> </FileStorage>
这种设计带来的最大好处是可测试性。单元测试时可以写一个基于内存字典的InMemoryFileStorage作为测试替身,测试既快又稳定,完全不依赖磁盘和网络。将来如果公司要接MinIO、阿里云OSS或者七牛,只需要新增一个实现类并在工厂里加一个分支,所有业务代码一行不动,这就是开闭原则在存储场景下的落地。
最后还有几个工程实践建议:上传大文件时优先考虑云端的分段上传能力,可以在接口上扩展一个UploadLargeAsync方法;为每个文件生成存储key时建议加入日期前缀,方便按时间归档和清理;另外别忘了给下载流加上超时控制,避免慢客户端长时间占用连接。把这套抽象搭好之后,存储后端对业务来说就真的只是一行配置的事了。