分片、预签名与命名实例
这一页讲对象存储的进阶能力:只读一段的范围读取、大文件的分片上传与断点续传、让客户端不带凭据直接上传下载的预签名地址(S3 原生签名与宿主代收发)、服务端只发票不经手内容的大文件直传,以及一个应用同时用多个存储时的命名存储实例。读完后,我们能做出视频拖动、断点续传和按租户分桶的存储方案。前置知识是读写与列举与ASP.NET Core 宿主。
为什么需要
几 GB 的视频一次 PUT 上传,网络一抖就得从头再来;所有下载都经业务服务器转发,带宽与线程被文件占满;多租户系统每个租户要用自己的桶和密钥。这三个问题分别由分片上传、预签名地址与命名存储实例解决:分片可乱序可重传、完成才可见;预签名把读写直接交给存储(S3)或宿主端点(文件系统),签名即授权、限时失效;命名实例在同一个进程里按名字管理多个存储,定义可以来自配置或应用的库。
概念
| 能力 | 契约 | 文件系统 | S3 |
|---|---|---|---|
objectstorage.multipart@1 | IBlobMultipartStore | 分片暂存在容器下的 uploads/<上传标识>/ | 原生 multipart |
objectstorage.presign@1 | IBlobUrlSigner | 没有原生签名,由 Hosting.AspNetCore 的 HostedBlobUrlFeature 代收发 | 签名 V4,客户端直连存储 |
| 命名实例 | BlobStores 注册表、BlobStoreDefinition | Provider 名 filesystem | Provider 名 s3 |
| 类型 | 说明 |
|---|---|
BlobRange(offset, length) | 读取范围;BlobContent.Range 是实际读到的 BlobContentRange(Offset、Length、Last、TotalLength) |
BlobMultipartUpload | 一次分片上传:容器、键、上传标识 UploadId、是否覆盖;可持久化、按同样的值重建 |
BlobPart | 一片:编号与 ETag |
BlobUrlRequest / BlobSignedUrl | 签名请求(方法、有效期、限定项)与签出的地址(Url、Method、ExpiresAt、Headers) |
最小示例
文件系统存储加宿主代收发,签出一个 30 分钟内有效的下载地址,前端拿地址直接下载,不带凭据:
using XiHan.Framework.Hosting.AspNetCore;
using XiHan.Framework.Hosting.AspNetCore.Blobs;
using XiHan.Framework.ObjectStorage.Abstractions.Signing;
using XiHan.Framework.ObjectStorage.FileSystem;
var builder = WebApplication.CreateBuilder(args);
builder.AddXiHan(xihan => xihan
.UseFeature<FileSystemObjectStorageFeature>()
.UseFeature<HostedBlobUrlFeature>());
var app = builder.Build();
app.UseXiHan();
app.MapXiHanBlobs(); // GET / PUT <BaseUrl>/{container}/{**key}
app.MapGet("/invoices/{id}/link", async (string id, IBlobUrlSigner signer, CancellationToken ct) =>
{
var link = (await signer.SignAsync("invoices", $"2026/10/{id}.pdf", BlobUrlRequest.ForDownload(downloadFileName: "发票.pdf"), ct)).ValueOrThrow();
// link.Url 交给前端;link.ExpiresAt 之后失效
return new { Url = link.Url.ToString(), link.ExpiresAt };
});
app.Run();{
"XiHan": {
"ObjectStorage": {
"SignedUrls": {
"BaseUrl": "/blobs",
"Secret": "dev-signing-secret-at-least-32-bytes"
}
}
}
}运行后:GET /invoices/inv-1/link 返回形如 /blobs/invoices/2026/10/inv-1.pdf?… 的地址与失效时刻;浏览器打开它得到 PDF 与 Content-Disposition: attachment; filename*=…发票.pdf;改动地址里任何一个字符或过期后再访问得到 403(OBJECTSTORAGE_DENIED_001)。换成 S3ObjectStorageFeature 时去掉 HostedBlobUrlFeature 与 MapXiHanBlobs(),同一段签名代码签出的是直连存储的 S3 地址。
范围读取
音视频拖动、断点续传下载只读一段:
var opened = await store.GetAsync("videos", "raw/intro.mp4", new BlobRange(offset: 1_048_576, length: 65_536), cancellationToken);
if (opened.TryGetValue(out var part))
{
await using (part)
{
// Range 是实际读到的范围;Metadata.Length 仍是对象总长
var range = part.Range!;
Console.WriteLine($"bytes {range.Offset}-{range.Last}/{range.TotalLength}");
}
}长度为空时读到末尾,超出末尾时截断。起点不在对象内(含空对象)返回 OBJECTSTORAGE_VALIDATION_001。
分片上传
大文件分片上传、断点续传用 IBlobMultipartStore(能力 objectstorage.multipart@1),限制与 S3 一致:
StartAsync(container, key, options)发起上传,得到BlobMultipartUpload(容器、键、上传标识、是否覆盖)。它可以持久化,跨请求、跨进程续传时用同样的值重建。UploadPartAsync(upload, partNumber, stream)上传编号 1 到 10000 的一片,可乱序、可重传(后一次覆盖),返回该片的BlobPart(编号与 ETag)。ListPartsAsync(upload)按编号升序列出已上传的分片,续传时据此跳过已完成的片。CompleteAsync(upload, parts)用编号严格升序、ETag 与已上传一致的清单拼成对象;除最后一片外每片至少 5 MiB(BlobMultipart.MinPartBytes)。完成前对象不可见,完成是原子的。AbortAsync(upload)丢弃上传,上传不存在也成功。
| 情况 | 结果 |
|---|---|
| 清单为空、乱序、含未上传的片、ETag 不符或非末片过小 | OBJECTSTORAGE_VALIDATION_002,上传保留,可修正清单后再完成 |
| 上传不存在或已结束 | OBJECTSTORAGE_NOTFOUND_002 |
Overwrite = false 且对象已存在 | 发起与完成都返回 OBJECTSTORAGE_CONFLICT_001 |
文件系统把分片暂存在容器下的 uploads/<上传标识>/,暂存的分片不计入配额,超过 MultipartExpiryHours(缺省 168 小时)仍未完成的上传在启动扫描时清掉;S3 直接映射原生 multipart。
预签名地址
IBlobUrlSigner(能力 objectstorage.presign@1)签出限时地址,客户端不带凭据直接下载或上传:
| 请求 | 方法 | 用途 |
|---|---|---|
BlobUrlRequest.ForDownload(expiresIn, downloadFileName) | GET | 下载,支持 Range;可带保存的文件名(Content-Disposition) |
BlobUrlRequest.ForUpload(expiresIn, contentType) | PUT | 上传整个对象,可限定内容类型 |
BlobUrlRequest.ForPart(upload, partNumber, expiresIn) | PUT | 上传分片上传的某一片,响应头 ETag 就是这一片的 ETag |
有效期缺省 30 分钟(BlobUrlRequest.DefaultExpiresIn),范围 1 秒到 7 天,超出时构造请求抛出 ArgumentOutOfRangeException。签出的 BlobSignedUrl 带地址、方法、失效时刻与 Headers:客户端必须原样发送这些请求头(例如限定的 Content-Type),否则验签不过。签名在本地计算,不检查对象是否存在。
S3 由存储自己签名(签名 V4),客户端直连存储。文件系统没有原生签名,由宿主代收发:装配 HostedBlobUrlFeature 并映射端点(见最小示例),签名是对方法、容器、键、过期时刻与各限定项的 HMAC-SHA256,无状态,持有同一密钥的多个实例互认。
| 键 | 缺省 | 说明 |
|---|---|---|
XiHan:ObjectStorage:SignedUrls:BaseUrl | /blobs | 签出地址的前缀:以 / 开头的路径(相对应用的源)或绝对地址;端点的路由前缀取它的路径部分 |
XiHan:ObjectStorage:SignedUrls:Secret | 无,必填 | 签名密钥,UTF-8 至少 32 字节;多实例部署必须一致;缺失或过短时装配抛出 ArgumentException |
宿主端点 GET 支持单个 Range 区间(206 与 Content-Range,不可满足回 416),PUT 写整个对象或一片并在响应头带 ETag。端点经内核管道,Observe 照常记录,不要求凭据,签名即授权;上传解除了请求体大小上限,单对象上限由存储把关。签名不符或过期回 403(OBJECTSTORAGE_DENIED_001)。没有装配 HostedBlobUrlFeature 就调用 MapXiHanBlobs(),调用处抛出 FrameworkException(KERNEL_HOSTING_004)。
端点的响应都带 X-Content-Type-Options: nosniff。写出内容时还带沙箱的 Content-Security-Policy:用户上传的 HTML、SVG 在这里跑不了脚本,图片、视频和 PDF 照常预览。把这个地址上的 HTML 当网页用的应用,在 UseXiHan 之前的中间件里设自己的 Content-Security-Policy,端点不覆盖。S3 直传直下的地址不经宿主,不受这一条影响。
不要同时装 S3 与宿主代收发
S3ObjectStorageFeature 与 HostedBlobUrlFeature 都提供 objectstorage.presign@1,同时装配时要用 XiHan:Providers:objectstorage.presign 指定一个,否则启动失败。S3 自己能签名,用 S3 时不装 HostedBlobUrlFeature。
大文件直传
服务端发起分片上传并签出各片地址,客户端按片直接传给存储(或宿主端点),最后由服务端完成。业务服务器不经手文件内容:
using XiHan.Framework.ObjectStorage.Abstractions.Blobs;
using XiHan.Framework.ObjectStorage.Abstractions.Multipart;
using XiHan.Framework.ObjectStorage.Abstractions.Signing;
// 1. 发起:返回上传标识与每一片的签名地址
app.MapPost("/videos/{name}/uploads", async (string name, int parts, IBlobMultipartStore multipart, IBlobUrlSigner signer, CancellationToken ct) =>
{
var upload = (await multipart.StartAsync("videos", $"raw/{name}", new BlobPutOptions { ContentType = "video/mp4" }, ct)).ValueOrThrow();
var urls = new List<string>(parts);
for (var number = 1; number <= parts; number++)
{
var signed = (await signer.SignAsync(upload.Container, upload.Key, BlobUrlRequest.ForPart(upload, number), ct)).ValueOrThrow();
urls.Add(signed.Url.ToString());
}
return new UploadTicket(upload.UploadId, urls);
});
// 2. 客户端逐片 PUT 到签名地址,记下每个响应的 ETag
// 3. 完成:上传标识可持久化,这里按同样的容器与键重建
app.MapPost("/videos/{name}/uploads/{uploadId}/complete", async (string name, string uploadId, CompletedPart[] completed, IBlobMultipartStore multipart, CancellationToken ct) =>
{
var upload = new BlobMultipartUpload("videos", $"raw/{name}", uploadId);
var parts = completed.OrderBy(static p => p.Number).Select(static p => new BlobPart(p.Number, p.ETag)).ToList();
var done = (await multipart.CompleteAsync(upload, parts, ct)).ValueOrThrow();
return new { Key = done.Key.Value, done.Length, done.ETag };
});
public sealed record UploadTicket(string UploadId, IReadOnlyList<string> PartUrls);
public sealed record CompletedPart(int Number, string ETag);用 Native AOT 发布时,UploadTicket、CompletedPart 要加进应用的 JsonSerializerContext,见JSON 序列化与 Native AOT。
命名存储实例
一个应用同时用多个存储(按租户或按用途各配一个桶,各有自己的端点与密钥)时,用注册表 BlobStores 管理命名实例。装配任一存储包就有这个单例,装配了的存储包都登记自己的命名实例 Provider,与它是否被选为缺省实例无关;缺省的 IBlobStore 注入方式不变,命名实例与缺省实例互不相干。
实例的定义 BlobStoreDefinition 是实例名(^[a-z0-9][a-z0-9-]{0,63}$)、Provider 名(filesystem 或 s3,不区分大小写)与设置。设置的键与该 Provider 的配置节相同、不区分大小写,未知的键是错误,拼错的键不会被静默忽略。定义有两个来源:
- 配置节
XiHan:ObjectStorage:Stores:<name>:Provider加设置,首次取用时建出。 - 运行时登记:
GetOrAddAsync(definition),定义可以来自应用自己的库。
{
"XiHan": {
"ObjectStorage": {
"Stores": {
"archive": { "Provider": "filesystem", "RootPath": "/var/lib/app/archive" },
"media": { "Provider": "s3", "ServiceUrl": "http://localhost:9000", "AccessKey": "dev-access-key", "SecretKey": "dev-secret-key", "ForcePathStyle": "true", "HealthBucket": "media" }
}
}
}
}using XiHan.Framework.ObjectStorage.Abstractions.Stores;
// 配置里定义的实例
var archive = (await stores.GetAsync("archive", cancellationToken)).ValueOrThrow();
// 来自应用库的存储配置:同名同定义复用同一实例,定义变了就建新实例并释放旧的
var definition = new BlobStoreDefinition("tenant-42", "s3", new Dictionary<string, string>
{
["ServiceUrl"] = tenant.StorageEndpoint,
["AccessKey"] = tenant.StorageAccessKey,
["SecretKey"] = tenant.StorageSecretKey,
});
var tenantStore = (await stores.GetOrAddAsync(definition, cancellationToken)).ValueOrThrow();stores 是注入的 BlobStores,tenant 是应用自己的租户存储配置。注册表的语义:
| 操作 | 结果 |
|---|---|
GetAsync(name) | 已建的直接返回;配置里有定义的首次取用时建出;都没有 → OBJECTSTORAGE_NOTFOUND_003 |
GetOrAddAsync(definition) | Provider 名与设置完全相同时返回已有实例;定义变了建新实例替换并释放旧的,替换时仍在旧实例上的调用可能失败 |
| Provider 未登记或设置不合法 | OBJECTSTORAGE_VALIDATION_003,已有的同名实例不受影响 |
| 后端此刻建不出来(目录无权限、磁盘错误) | OBJECTSTORAGE_UNAVAILABLE_001,已有的同名实例不受影响 |
| 建实例时的探测发现实例不能用(S3 探明服务不认条件写入、探测被拒绝或探测桶不存在) | Provider 给出的错误码(OBJECTSTORAGE_UNAVAILABLE_003、OBJECTSTORAGE_DENIED_002、OBJECTSTORAGE_NOTFOUND_001),不登记,下次取用重新探测,已有的同名实例不受影响;S3 只是暂时连不上时照常建出,后台重试 |
RemoveAsync(name) | 移除并释放,返回是否存在过;配置里有定义的实例之后再取用会重新建出 |
文件系统的命名实例必须给 RootPath,否则会与缺省实例落在同一目录。S3 的命名实例各建各的客户端,不读连接绑定,建出时在实例的 HealthBucket 里探测服务是否认条件写入,实例被替换、移除或应用停止时停止重试、释放客户端。命名实例的能力随实例本身:S3 实例同时是 IBlobMultipartStore 与 IBlobUrlSigner;文件系统实例实现分片,预签名经宿主签名器的 ForStore(name),签出的地址带 x-store=<name>,实例名在签名里,改成别的实例或去掉都验签不过:
var signer = app.Services.GetRequiredService<HostedBlobUrlSigner>().ForStore("archive");
var url = (await signer.SignAsync("files", "docs/a.txt", BlobUrlRequest.ForDownload(), cancellationToken)).ValueOrThrow().Url;每个实例一条健康项 objectstorage.store.<name>,按 XiHan:ObjectStorage:StoreHealthProbeIntervalSeconds(缺省 15 秒)在后台逐个探测,新实例建出后立即探一次;还没探过为 Started,探测失败为 Degraded,实例仍可取用。文件系统看总配额是否还有余量,S3 对实例的 HealthBucket 做 HEAD(没配时列桶),连通之后再看条件写入,尚未确认或不认时 Degraded。健康项的汇总见生命周期与健康。
S3 命名实例的定义可能来自应用的库,端点可能是租户填的,所以它的客户端过出站地址策略,缺省只能连公网地址。自建的 MinIO 等要放行,比如把主机名写进 XiHan:Security:Outbound:AllowedHosts。被拒绝与连不上同样处理:建实例时的探测只算尚未确认,读写返回 OBJECTSTORAGE_UNAVAILABLE_001,日志里有一条 Warning。缺省实例的端点是运维配的,不过策略。
混装文件系统与 S3 时怎样指定缺省存储
缺省的 IBlobStore、IBlobMultipartStore、IBlobUrlSigner 必须落在同一存储上,这条规则只约束被要求的契约:有特性在清单里依赖了这项能力,或 XiHan:Providers / UseProvider 指定了它的提供者。不指定时 S3 是显式 Provider,三项都归 S3,文件系统被顶替,但它的命名实例 Provider 照样登记(被顶替的存储包只登记命名实例,不建缺省实例、不启动)。
以文件系统为缺省实例、只用 S3 的命名实例时,指定对象存储一项就够:
{
"XiHan": {
"Providers": {
"objectstorage.blob": "xihan.objectstorage.filesystem"
}
}
}这时 S3 的分片上传与预签名只是兜底选出(没人依赖、没人指定),S3 按被顶替处理,只登记命名实例;容器里没有缺省的 IBlobMultipartStore 与 IBlobUrlSigner,也不必装 HostedBlobUrlFeature。要用缺省的分片上传,再指定 "objectstorage.multipart": "xihan.objectstorage.filesystem";要用缺省的预签名,装配 HostedBlobUrlFeature 并指定 "objectstorage.presign": "xihan.hosting.blob-urls"(它与 S3 都是显式 Provider,不指定本来就是 KERNEL_CAPABILITY_004)。
存储包被要求提供分片上传或预签名、却没被选为对象存储时,装配抛出 OBJECTSTORAGE_VALIDATION_004,应用启动失败,消息列出被要求的能力与要改的键。选择规则见能力图谱与 Provider。
常见问题
分片要多大,可以不按顺序传吗
除最后一片外每片至少 5 MiB,编号 1 到 10000,可乱序、可重传,后一次覆盖前一次。完成时清单必须按编号严格升序、ETag 与已上传一致,否则 OBJECTSTORAGE_VALIDATION_002,上传保留,修正清单再完成。
浏览器刷新了,分片上传怎么续
把 BlobMultipartUpload 的容器、键、上传标识存进业务表;续传时用同样的值重建,ListPartsAsync 列出已上传的片,跳过它们继续传。未完成的上传在文件系统上保留 MultipartExpiryHours,S3 上按桶的生命周期规则。
预签名地址泄露了怎么办
地址在 ExpiresAt 之后自动失效,有效期最长 7 天,按业务取最短可用值(下载几分钟、上传按文件大小估)。宿主代收发时轮换 Secret 即让全部已签地址失效;S3 的地址随凭据轮换失效。
用 S3 还要不要 HostedBlobUrlFeature
不要。S3 自己签名、客户端直连存储;两者都提供 objectstorage.presign@1,同时装配要显式指定一个,否则启动失败。
每个租户一个桶,怎样切换
用命名实例:租户的端点与密钥存在应用的库里,取用时构造 BlobStoreDefinition 调 GetOrAddAsync,同名同定义复用、定义变了自动换新。注意租户填的端点经出站地址策略,内网 MinIO 要先放行。
相关包与下一步
- ObjectStorage.Abstractions:分片、签名与命名实例的契约
- ObjectStorage.FileSystem、ObjectStorage.S3:两个实现的配置与行为
- Hosting.AspNetCore:
HostedBlobUrlFeature与MapXiHanBlobs - 下一章:全球化和本地化
