摘要:深入解析一个生产级的阿里云OSS工具类实现,涵盖静态单例模式、流式上传下载、目录级联删除、私有文件签名URL生成等核心功能。代码基于
Aliyun.OSSSDK,兼容 .NET Framework 4.6.1+ 及 .NET Core/5/6/7/8+ 项目。
一、前言
在.NET后端开发中,对象存储服务(OSS)是处理文件上传、下载、管理的标配方案。阿里云OSS作为国内主流对象存储服务,其.NET SDK功能完善,但直接使用SDK编写业务代码往往存在以下痛点:
痛点 具体表现 配置分散 AccessKey、Endpoint、BucketName散落在各处 路径混乱 Windows反斜杠与OSS正斜杠混用导致404 异常处理不统一 每个调用方自行处理OssException 重复造轮子 签名URL生成、目录批量删除等逻辑反复编写
本文分享的 AliyunOssHelper 工具类,正是为了解决上述问题而设计的高内聚、低耦合、线程安全的静态工具类。
二、核心设计思路
2.1 为什么选择静态单例模式?
方案 优点 缺点 适用场景 每次 new OssClient 简单直观 连接开销大,线程不安全 临时脚本 依赖注入(DI) 符合现代.NET风格 需要容器支持,旧项目改造成本高 新项目、微服务 静态Lazy单例 ✅ 线程安全、延迟加载、零依赖 单元测试时难以Mock 工具类、遗留系统
对于工具类场景,静态单例是性价比最高的选择。使用 Lazy<T> 确保:
- ✅ 多线程环境下只初始化一次
- ✅ 首次调用时才创建实例(延迟加载)
- ✅ 无需外部容器即可工作
2.2 配置集中管理
所有配置项通过 AppSettings.GetConfig 统一读取,键值采用冒号分隔(兼容.NET Core配置风格):
json
{
"ALIYUN_OSS": {
"KEY": "your-access-key-id",
"SECRET": "your-access-key-secret",
"Endpoint": "https://oss-cn-hangzhou.aliyuncs.com",
"bucketName": "my-bucket",
"domainUrl": "https://cdn.example.com"
}
}
2.3 路径规范化策略
OSS对象键(ObjectKey)必须以 / 分隔,且不能以 / 开头。代码中统一处理:
csharp
dirPath = dirPath.Replace(@"\", "/").TrimStart('/');
⚠️ 注意:使用逐字字符串
@"\"避免转义问题,比"\\"更清晰。
三、功能模块详解
3.1 文件上传(流式上传)
支持传入 Stream 对象上传,适用于:
- 接收前端上传的文件流
- 内存中生成的文件(如报表、二维码)
- 第三方接口转发
csharp
// 使用示例
using var fs = System.IO.File.OpenRead("report.pdf");
var status = AliyunOssHelper.PutObjectFromFile(fs, "reports/2024/report.pdf");
if (status == HttpStatusCode.OK)
{
Console.WriteLine("✅ 上传成功");
}
关键细节:
细节 实现 空流校验 自动校验流是否可读,避免空引用异常 路径规范化 自动替换反斜杠、去除前导斜杠 兼容旧版 UploadStream 方法做参数顺序适配
3.2 文件下载(内存流返回)
csharp
using var stream = AliyunOssHelper.GetObjectStream("images/logo.png");
// 可直接返回给前端,或写入本地文件
⚠️ 性能提示:当前实现将OSS流完整复制到
MemoryStream。对于大文件(>100MB),建议改用分块下载或直传流,避免内存溢出。
3.3 文件删除(级联清理)
这是业务属性最强的功能。当删除压缩包(.hnt / .zip / .itz)时,自动清理其解压后的目录:
csharp
// 删除压缩包,同时自动删除同名目录下的所有文件
AliyunOssHelper.DeleteFile("packages/app_v1.0.zip");
// 会级联删除 packages/app_v1.0/ 下的所有对象
实现原理:
bash
┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐
│ 1. 删除源文件 │ ──▶ │ 2. 检查后缀类型 │ ──▶ │ 3. 拼接目录前缀 │
│ DeleteObject │ │ .hnt/.zip/.itz │ │ noSuffix + "/" │
└─────────────────┘ └─────────────────┘ └─────────────────┘
│
┌─────────────────────────┘
▼
┌─────────────────┐
│ 4. ListObjects │
│ 确认目录存在 │
└─────────────────┘
│
▼
┌─────────────────┐
│ 5. 分页批量删除 │
│ MaxKeys=1000 │
└─────────────────┘
3.4 目录管理
列出子目录
OSS本身没有"目录"概念,只有对象键前缀。通过设置 Delimiter = "/" 模拟目录结构:
csharp
var dirs = AliyunOssHelper.ListDirectories("images/");
// 返回:["images/avatars/", "images/banners/", "images/products/"]
列出文件(按后缀过滤)
csharp
var files = AliyunOssHelper.ListFiles("data", "exports", ".csv");
// 返回 data/exports/ 下所有 .csv 文件路径
3.5 私有文件签名URL
对于私有Bucket,临时访问URL需要签名。工具类提供了两种方法:
方法 特点 适用场景 GenerateSignedGetUrl 直接生成签名URL 确定文件存在时 GenerateSignedGetUrlIfExists 先校验存在性再生成 不确定文件是否存在
csharp
// 直接生成(1小时有效)
var url = AliyunOssHelper.GenerateSignedGetUrl("confidential/report.pdf", expireSeconds: 3600);
// 返回:https://cdn.example.com/confidential/report.pdf?OSSAccessKeyId=...&Expires=...&Signature=...
// 存在性校验后生成
var url = AliyunOssHelper.GenerateSignedGetUrlIfExists("file.pdf");
// 文件不存在返回空字符串,避免生成无效URL
性能优化:签名URL生成涉及HMAC-SHA1计算,工具类内置了10分钟本地缓存,相同参数的请求直接返回缓存结果。
四、完整源码
csharp
using Aliyun.OSS;
using Aliyun.OSS.Common;
using Infrastructure;
using System;
using System.Collections.Generic;
using System.IO;
using System.IO.Compression;
using System.Linq;
using System.Net;
using System.Threading.Tasks;
namespace Common.Aliyun
{
/// <summary>
/// 阿里云OSS工具类
/// 特性:静态单例、高性能、无内存泄漏、异常安全
/// 功能:文件上传/下载/删除、压缩包解压、目录管理、私有文件签名URL
/// </summary>
public static class AliyunOssHelper
{
#region 静态配置
/// <summary>阿里云AccessKey ID</summary>
private static readonly string AccessKeyId = AppSettings.GetConfig("ALIYUN_OSS:KEY");
/// <summary>阿里云AccessKey Secret</summary>
private static readonly string AccessKeySecret = AppSettings.GetConfig("ALIYUN_OSS:SECRET");
/// <summary>OSS服务访问域名</summary>
private static readonly string Endpoint = AppSettings.GetConfig("ALIYUN_OSS:Endpoint");
/// <summary>默认存储空间名称</summary>
private static readonly string DefaultBucketName = AppSettings.GetConfig("ALIYUN_OSS:bucketName");
/// <summary>自定义访问域名</summary>
private static readonly string DomainUrl = AppSettings.GetConfig("ALIYUN_OSS:domainUrl");
/// <summary>
/// 延迟初始化OSS客户端(单例模式,保证线程安全+性能最优)
/// </summary>
private static readonly Lazy<OssClient> _ossClient = new(() =>
new OssClient(Endpoint, AccessKeyId, AccessKeySecret));
/// <summary>OSS客户端实例</summary>
private static OssClient Client => _ossClient.Value;
#endregion
#region 基础校验
/// <summary>校验字符串参数不能为空</summary>
/// <exception cref="ArgumentNullException">参数为空时抛出</exception>
private static void CheckNull(string value, string name)
{
if (string.IsNullOrWhiteSpace(value))
throw new ArgumentNullException(name);
}
#endregion
#region 文件上传
/// <summary>
/// 流式上传文件到OSS
/// </summary>
/// <param name="filestreams">文件流</param>
/// <param name="dirPath">OSS存储路径</param>
/// <param name="bucketName">指定存储空间(为空使用默认值)</param>
/// <returns>HTTP状态码</returns>
public static HttpStatusCode PutObjectFromFile(Stream filestreams, string dirPath, string bucketName = "")
{
CheckNull(dirPath, nameof(dirPath));
if (filestreams == null || !filestreams.CanRead)
return HttpStatusCode.BadRequest;
try
{
var bucket = string.IsNullOrEmpty(bucketName) ? DefaultBucketName : bucketName;
// 统一路径格式:替换反斜杠+去除开头斜杠
dirPath = dirPath.Replace(@"\", "/").TrimStart('/');
var result = Client.PutObject(bucket, dirPath, filestreams);
return result.HttpStatusCode;
}
catch (OssException ex)
{
Console.WriteLine($"OSS上传异常:{ex.ErrorCode} {ex.Message}");
}
catch (Exception ex)
{
Console.WriteLine($"上传失败:{ex.Message}");
}
return HttpStatusCode.BadRequest;
}
/// <summary>
/// 流式上传(兼容旧版调用,参数顺序不同)
/// </summary>
public static HttpStatusCode UploadStream(string ossPath, Stream stream, string bucketName = "")
{
return PutObjectFromFile(stream, ossPath, bucketName);
}
#endregion
#region 文件获取
/// <summary>
/// 获取OSS文件流(内存流返回)
/// </summary>
/// <param name="ossFilePath">OSS文件路径</param>
/// <param name="bucketName">指定存储空间(为空使用默认值)</param>
/// <returns>文件内存流</returns>
/// <exception cref="Exception">OSS读取异常</exception>
public static Stream GetObjectStream(string ossFilePath, string bucketName = "")
{
CheckNull(ossFilePath, nameof(ossFilePath));
try
{
var bucket = string.IsNullOrEmpty(bucketName) ? DefaultBucketName : bucketName;
ossFilePath = ossFilePath.Replace(@"\", "/").TrimStart('/');
using var ossObject = Client.GetObject(bucket, ossFilePath);
var ms = new MemoryStream();
ossObject.Content.CopyTo(ms);
ms.Position = 0;
return ms;
}
catch (OssException ex)
{
throw new Exception($"OSS读取失败:{ex.ErrorCode} {ex.Message}", ex);
}
}
/// <summary>获取OSS文本文件内容</summary>
public static string GetObjectString(string ossFilePath, string bucketName = "")
{
using var stream = GetObjectStream(ossFilePath, bucketName);
using var reader = new StreamReader(stream);
return reader.ReadToEnd();
}
#endregion
#region 文件删除
/// <summary>
/// 删除OSS文件,并自动删除对应解压目录(支持.hnt/.zip/.itz)
/// </summary>
/// <param name="dirPath">OSS文件路径</param>
/// <param name="bucketName">指定存储空间(为空使用默认值)</param>
/// <returns>HTTP状态码</returns>
public static HttpStatusCode DeleteFile(string dirPath, string bucketName = "")
{
CheckNull(dirPath, nameof(dirPath));
try
{
var bucket = string.IsNullOrEmpty(bucketName) ? DefaultBucketName : bucketName;
dirPath = dirPath.Replace(@"\", "/").TrimStart('/');
// 1. 删除源文件
Client.DeleteObject(bucket, dirPath);
// 2. 匹配指定后缀,删除对应解压目录
string[] targetSuffixes = { ".hnt", ".zip", ".itz" };
string extension = Path.GetExtension(dirPath);
if (targetSuffixes.Contains(extension, StringComparer.OrdinalIgnoreCase))
{
// 拼接目录前缀
string noSuffixPath = Path.ChangeExtension(dirPath, null);
string folderPrefix = noSuffixPath.Replace(@"\", "/").TrimStart('/') + "/";
// 校验目录是否存在
var listReq = new ListObjectsRequest(bucket)
{
Prefix = folderPrefix,
MaxKeys = 1
};
var listResult = Client.ListObjects(listReq);
if (listResult.ObjectSummaries.Any())
{
DeleteOssFolder(bucket, folderPrefix);
}
}
return HttpStatusCode.OK;
}
catch (OssException ex)
{
Console.WriteLine($"OSS删除异常:{ex.ErrorCode} {ex.Message}");
}
catch (Exception ex)
{
Console.WriteLine($"删除失败:{ex.Message}");
}
return HttpStatusCode.BadRequest;
}
/// <summary>递归删除OSS目录下所有文件(分页批量删除)</summary>
private static void DeleteOssFolder(string bucketName, string folderPrefix)
{
var listRequest = new ListObjectsRequest(bucketName)
{
Prefix = folderPrefix,
MaxKeys = 1000
};
// 分页删除(OSS单次最大1000条)
do
{
var result = Client.ListObjects(listRequest);
if (!result.ObjectSummaries.Any())
break;
var keys = result.ObjectSummaries.Select(x => x.Key).ToList();
Client.DeleteObjects(new DeleteObjectsRequest(bucketName, keys));
listRequest.Marker = result.NextMarker;
} while (!string.IsNullOrEmpty(listRequest.Marker));
}
#endregion
#region 文件/目录判断
/// <summary>判断OSS文件/对象是否存在</summary>
public static bool DoesObjectExist(string ossPath, string bucketName = "")
{
if (string.IsNullOrWhiteSpace(ossPath)) return false;
try
{
var bucket = string.IsNullOrEmpty(bucketName) ? DefaultBucketName : bucketName;
ossPath = ossPath.Replace(@"\", "/").TrimStart('/');
return Client.DoesObjectExist(bucket, ossPath);
}
catch
{
return false;
}
}
#endregion
#region 文件列表
/// <summary>获取OSS指定目录下的所有文件路径</summary>
public static List<string> ListObjects(string prefix, string bucketName = "")
{
var list = new List<string>();
try
{
var bucket = string.IsNullOrEmpty(bucketName) ? DefaultBucketName : bucketName;
prefix = prefix.Replace(@"\", "/").TrimStart('/');
if (!prefix.EndsWith("/")) prefix += "/";
var req = new ListObjectsRequest(bucket)
{
Prefix = prefix,
MaxKeys = 1000
};
var result = Client.ListObjects(req);
foreach (var summary in result.ObjectSummaries)
{
list.Add(summary.Key);
}
}
catch (Exception ex)
{
Console.WriteLine($"列出OSS文件失败:{ex.Message}");
}
return list;
}
/// <summary>
/// 获取OSS指定目录下指定后缀的文件列表(适配ITZ UUID获取)
/// </summary>
public static List<string> ListFiles(string prefix, string folder, string extension, string bucketName = "")
{
var fileList = new List<string>();
try
{
var fullPrefix = Path.Combine(prefix, folder).Replace(@"\", "/");
var allObjects = ListObjects(fullPrefix, bucketName);
fileList = allObjects
.Where(x => x.EndsWith(extension, StringComparison.OrdinalIgnoreCase))
.ToList();
}
catch (Exception ex)
{
Console.WriteLine($"获取OSS文件列表失败:{ex.Message}");
}
return fileList;
}
#endregion
#region 压缩包解压
/// <summary>
/// 解压OSS压缩包并上传至OSS(支持zip,纯内存操作无本地文件,全部文件同步上传)
/// </summary>
/// <param name="ossArchivePath">OSS压缩包路径</param>
/// <param name="targetOssDir">解压目标目录</param>
/// <param name="bucketName">指定存储空间(为空使用默认值)</param>
/// <returns>已上传的XML文件OSS路径列表</returns>
public static List<string> DecompressToOss(string ossArchivePath, string targetOssDir, string bucketName = "")
{
List<string> xmlFilePaths = new List<string>();
CheckNull(ossArchivePath, nameof(ossArchivePath));
CheckNull(targetOssDir, nameof(targetOssDir));
try
{
var bucket = string.IsNullOrEmpty(bucketName) ? DefaultBucketName : bucketName;
ossArchivePath = ossArchivePath.Replace(@"\", "/").TrimStart('/');
targetOssDir = targetOssDir.Replace(@"\", "/").TrimEnd('/') + "/";
// 目录已存在直接返回
if (DoesObjectExist($"{targetOssDir}/", bucket))
{
return xmlFilePaths;
}
// 获取压缩包OSS流
using Stream archiveStream = GetObjectStream(ossArchivePath, bucket);
if (archiveStream == null || !archiveStream.CanRead)
throw new Exception("压缩包流读取失败");
// 内部就地解压
var fileDict = ExtractAllFilesToStream(archiveStream);
if (fileDict == null || !fileDict.Any())
throw new Exception("压缩包内无文件");
// 全部文件同步逐个上传
foreach (var kv in fileDict)
{
try
{
string ossKey = $"{targetOssDir}{kv.Key.Replace(@"\", "/")}";
if (kv.Value.CanSeek) kv.Value.Position = 0;
var status = UploadStream(ossKey, kv.Value, bucket);
// 仅XML记录返回路径
if (status == HttpStatusCode.OK &&
kv.Key.EndsWith(".xml", StringComparison.OrdinalIgnoreCase))
{
xmlFilePaths.Add(ossKey);
}
}
catch
{
// 单个文件异常不中断后续上传
}
finally
{
kv.Value.Dispose();
}
}
return xmlFilePaths;
}
catch
{
return xmlFilePaths;
}
}
/// <summary>
/// 解压所有文件到内存流(纯内存,适配OSS上传)
/// </summary>
/// <param name="zipStream">压缩文件流</param>
/// <returns>key=文件名, value=文件内存流</returns>
private static Dictionary<string, Stream> ExtractAllFilesToStream(Stream zipStream)
{
var fileDictionary = new Dictionary<string, Stream>();
if (zipStream == null || !zipStream.CanRead)
throw new ArgumentNullException(nameof(zipStream));
try
{
if (zipStream.CanSeek)
zipStream.Position = 0;
using var archive = new ZipArchive(zipStream, ZipArchiveMode.Read, leaveOpen: true);
foreach (var entry in archive.Entries)
{
// 跳过目录
if (string.IsNullOrEmpty(entry.Name))
continue;
var ms = new MemoryStream();
using var entryStream = entry.Open();
entryStream.CopyTo(ms);
ms.Position = 0;
fileDictionary.Add(entry.FullName, ms);
}
return fileDictionary;
}
catch (Exception ex)
{
throw new Exception($"解压所有文件失败: {ex.Message}", ex);
}
}
#endregion
#region 目录管理
/// <summary>
/// 获取OSS指定目录下的所有子目录(仅返回目录,不包含文件)
/// </summary>
/// <param name="prefix">父目录路径</param>
/// <param name="bucketName">指定存储空间(为空使用默认值)</param>
/// <returns>子目录路径列表</returns>
public static List<string> ListDirectories(string prefix, string bucketName = "")
{
var directoryList = new List<string>();
try
{
var bucket = string.IsNullOrEmpty(bucketName) ? DefaultBucketName : bucketName;
prefix = prefix.Replace(@"\", "/").TrimStart('/');
if (!prefix.EndsWith("/")) prefix += "/";
// Delimiter=/ 为OSS目录分隔核心参数,用于筛选目录
var req = new ListObjectsRequest(bucket)
{
Prefix = prefix,
Delimiter = "/",
MaxKeys = 1000
};
var result = Client.ListObjects(req);
// CommonPrefixes 为OSS返回的子目录集合
foreach (var dirPrefix in result.CommonPrefixes)
{
directoryList.Add(dirPrefix);
}
}
catch (Exception ex)
{
Console.WriteLine($"获取OSS子目录失败:{ex.Message}");
}
return directoryList;
}
#endregion
#region 私有文件签名URL
/// <summary>
/// 生成OSS私有文件临时访问签名URL
/// </summary>
/// <param name="objectPath">OSS文件路径/完整URL</param>
/// <param name="expireSeconds">有效期(秒),范围60~86400,默认1小时</param>
/// <param name="bucketName">指定存储空间(为空使用默认值)</param>
/// <returns>签名URL,失败返回空字符串</returns>
public static string GenerateSignedGetUrl(string objectPath, int expireSeconds = 3600, string bucketName = "")
{
if (string.IsNullOrWhiteSpace(objectPath))
return string.Empty;
try
{
// 兼容传入完整URL,自动提取路径
string ossPath = objectPath;
if (Uri.TryCreate(objectPath, UriKind.Absolute, out var uri))
ossPath = uri.AbsolutePath.TrimStart('/');
// 限制有效期范围
expireSeconds = Math.Clamp(expireSeconds, 60, 86400);
var bucket = string.IsNullOrEmpty(bucketName) ? DefaultBucketName : bucketName;
ossPath = ossPath.Replace(@"\", "/").TrimStart('/');
// 本地缓存签名URL,避免重复生成
var cacheKey = $"OSS_SIGN_{bucket}_{ossPath.Replace("/", "_")}_{expireSeconds}";
var cached = CacheHelper.GetCache<string>(cacheKey);
if (!string.IsNullOrEmpty(cached))
return cached;
// 生成签名
var expireTime = DateTime.Now.AddSeconds(expireSeconds);
var signedUri = Client.GeneratePresignedUri(bucket, ossPath, expireTime, SignHttpMethod.Get);
// 拼接自定义域名+签名参数
var final = $"{DomainUrl.TrimEnd('/')}/{ossPath}{signedUri.Query}";
// 缓存10分钟,保证返回的URL始终有效
CacheHelper.SetCaches(cacheKey, final, 600);
return final;
}
catch
{
return string.Empty;
}
}
/// <summary>
/// 校验文件存在后,生成私有文件临时访问签名URL
/// </summary>
/// <returns>签名URL,文件不存在/异常返回空字符串</returns>
public static string GenerateSignedGetUrlIfExists(string objectPath, int expireSeconds = 3600, string bucketName = "")
{
try
{
if (string.IsNullOrWhiteSpace(objectPath))
return string.Empty;
// 统一路径处理
string ossPath = objectPath;
if (Uri.TryCreate(objectPath, UriKind.Absolute, out var uri))
ossPath = uri.AbsolutePath.TrimStart('/');
var bucket = string.IsNullOrEmpty(bucketName) ? DefaultBucketName : bucketName;
ossPath = ossPath.Replace(@"\", "/").TrimStart('/');
// 文件不存在直接返回空
if (!Client.DoesObjectExist(bucket, ossPath))
return string.Empty;
return GenerateSignedGetUrl(objectPath, expireSeconds, bucketName);
}
catch
{
return string.Empty;
}
}
#endregion
}
}
五、使用示例
5.1 appsettings.json 配置
json
{
"ALIYUN_OSS": {
"KEY": "LTAI5t8Z3y8Y7Z7Z7Z7Z7Z7Z",
"SECRET": "your-secret-key",
"Endpoint": "https://oss-cn-hangzhou.aliyuncs.com",
"bucketName": "my-app-bucket",
"domainUrl": "https://cdn.myapp.com"
}
}
5.2 常见业务场景
csharp
// ── 场景1:上传用户头像 ──────────────────────────────
public async Task<string> UploadAvatar(IFormFile file, int userId)
{
var ossPath = $"avatars/{userId}/{Guid.NewGuid()}.jpg";
using var stream = file.OpenReadStream();
var status = AliyunOssHelper.PutObjectFromFile(stream, ossPath);
return status == HttpStatusCode.OK ? ossPath : null;
}
// ── 场景2:获取私有文件临时链接 ──────────────────────
public string GetPrivateFileUrl(string filePath)
{
return AliyunOssHelper.GenerateSignedGetUrl(filePath, expireSeconds: 1800);
}
// ── 场景3:清理过期压缩包(级联删除)────────────────
public void CleanupPackage(string packagePath)
{
AliyunOssHelper.DeleteFile(packagePath); // 自动删除解压目录
}
// ── 场景4:遍历目录获取所有CSV数据文件 ───────────────
public List<string> GetDataFiles()
{
return AliyunOssHelper.ListFiles("data", "exports", ".csv");
}
六、注意事项与优化建议
类型 注意事项 说明 ⚠️ 大文件下载 GetObjectStream 使用 MemoryStream 缓存,大文件建议改为流式直传 ⚠️ 异步支持 当前为同步实现,高并发场景建议补充 async/await 版本 ⚠️ 日志记录 当前使用 Console.WriteLine,生产环境建议接入 ILogger 或 Serilog ⚠️ 配置热更新 静态字段在程序启动时加载,配置变更需重启应用 ✅ 路径安全 自动处理反斜杠和前导斜杠,避免OSS 404 ✅ 签名缓存 10分钟本地缓存减少重复签名计算,提升性能
七、总结
AliyunOssHelper 是一个经过生产验证的OSS工具类,其核心优势在于:
优势 说明 零依赖启动 纯静态类,无需DI容器即可工作 线程安全 Lazy<T> 确保客户端单例安全 业务内聚 级联删除、签名缓存等逻辑封装到位 防御性编程 参数校验、异常捕获、路径规范化全覆盖
适用场景:中小型项目或遗留系统改造,作为OSS操作的统一入口。
进阶建议:对于大型微服务架构,建议进一步抽象为 IOssService 接口,便于单元测试和多厂商切换。
SDK版本:Aliyun.OSS.SDK.NetCore 2.13.0+
兼容框架:.NET Framework 4.6.1 / .NET Core 2.0 / .NET 5/6/7/8+
如果这篇文章对你有帮助,欢迎点赞、收藏、关注!有任何问题欢迎在评论区留言讨论。
👋 这是我网站:tool.bugcome.com 