XiHan Framework 处于预览阶段

查看获取方式
跳转到内容

分片、预签名与命名实例 ​

这一页讲对象存储的进阶能力:只读一段的范围读取、大文件的分片上传与断点续传、让客户端不带凭据直接上传下载的预签名地址(S3 原生签名与宿主代收发)、服务端只发票不经手内容的大文件直传,以及一个应用同时用多个存储时的命名存储实例。读完后,我们能做出视频拖动、断点续传和按租户分桶的存储方案。前置知识是读写与列举与ASP.NET Core 宿主。

为什么需要 ​

几 GB 的视频一次 PUT 上传,网络一抖就得从头再来;所有下载都经业务服务器转发,带宽与线程被文件占满;多租户系统每个租户要用自己的桶和密钥。这三个问题分别由分片上传、预签名地址与命名存储实例解决:分片可乱序可重传、完成才可见;预签名把读写直接交给存储(S3)或宿主端点(文件系统),签名即授权、限时失效;命名实例在同一个进程里按名字管理多个存储,定义可以来自配置或应用的库。

概念 ​

能力契约文件系统S3
objectstorage.multipart@1IBlobMultipartStore分片暂存在容器下的 uploads/<上传标识>/原生 multipart
objectstorage.presign@1IBlobUrlSigner没有原生签名,由 Hosting.AspNetCore 的 HostedBlobUrlFeature 代收发签名 V4,客户端直连存储
命名实例BlobStores 注册表、BlobStoreDefinitionProvider 名 filesystemProvider 名 s3
类型说明
BlobRange(offset, length)读取范围;BlobContent.Range 是实际读到的 BlobContentRange(Offset、Length、Last、TotalLength)
BlobMultipartUpload一次分片上传:容器、键、上传标识 UploadId、是否覆盖;可持久化、按同样的值重建
BlobPart一片:编号与 ETag
BlobUrlRequest / BlobSignedUrl签名请求(方法、有效期、限定项)与签出的地址(Url、Method、ExpiresAt、Headers)

最小示例 ​

文件系统存储加宿主代收发,签出一个 30 分钟内有效的下载地址,前端拿地址直接下载,不带凭据:

csharp
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();
json
{
  "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 地址。

范围读取 ​

音视频拖动、断点续传下载只读一段:

csharp
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 一致:

  1. StartAsync(container, key, options) 发起上传,得到 BlobMultipartUpload(容器、键、上传标识、是否覆盖)。它可以持久化,跨请求、跨进程续传时用同样的值重建。
  2. UploadPartAsync(upload, partNumber, stream) 上传编号 1 到 10000 的一片,可乱序、可重传(后一次覆盖),返回该片的 BlobPart(编号与 ETag)。
  3. ListPartsAsync(upload) 按编号升序列出已上传的分片,续传时据此跳过已完成的片。
  4. CompleteAsync(upload, parts) 用编号严格升序、ETag 与已上传一致的清单拼成对象;除最后一片外每片至少 5 MiB(BlobMultipart.MinPartBytes)。完成前对象不可见,完成是原子的。
  5. 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。

大文件直传 ​

服务端发起分片上传并签出各片地址,客户端按片直接传给存储(或宿主端点),最后由服务端完成。业务服务器不经手文件内容:

csharp
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),定义可以来自应用自己的库。
json
{
  "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" }
      }
    }
  }
}
csharp
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>,实例名在签名里,改成别的实例或去掉都验签不过:

csharp
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 的命名实例时,指定对象存储一项就够:

json
{
  "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 要先放行。

相关包与下一步 ​

Released under The MIT License