API 资产治理:ETag/Cache-Control/分页/排序/投影的“契约基线”
API 资产治理:ETag/Cache-Control/分页/排序/投影的“契约基线” 🚀
📚 目录
- API 资产治理:ETag/Cache-Control/分页/排序/投影的“契约基线” 🚀
- 0. TL;DR 🧭
- 合约基线鸟瞰
- 1. 背景与目标 🎯
- 2. 适用范围 📐
- 3. 资源命名与版本治理(ABP 落地) 🧩
- 4. 查询契约:分页 / 过滤 / 排序 / 投影 🔎
- 4.1 分页(游标优先) 🧭
- 4.2 排序(ThenBy 链式、字段白名单) 📑
- 4.3 投影(fields) ✂️
- 4.4 OData(可选,限幅一致) 🧱
- 5. 缓存契约:ETag / 条件请求 / Cache-Control ⚡
- 5.1 强/弱 ETag 与比较语义 🧠
- 5.2 统一 ETag 生成与 `Vary` 合并 🧩
- 5.3 条件 GET/HEAD(含 `*`)与并发写 🔒
- 5.4 列表端点:弱 ETag(**本页摘要**)与 304 元数据、绝对 `Link` 🔁
- 6. 错误与一致性:ProblemDetails + Trace Context 🆘
- 7. 安全与最小暴露 🛡️
- 8. 可观测与 SLO 🔭
- 9. “契约校验器”与门禁(Spectral + 代码级) 🧪
- 9.1 OpenAPI Lint(规则节选)
- 9.2 CI/门禁流水线(Mermaid)
- 10. 开发者门户(Swagger/Redoc) 📘
- 11. 兼容性测试集(Newman/Playwright/BDN) 🧪🧪
- 12. ABP vNext 工程化落地 🧱
- 13. 路线图 🗺️
- 常见误区 ✅
- 可复现指南(最小步骤) 🧰
0. TL;DR 🧭
-
契约基线:命名/版本/鉴权 → 分页/过滤/排序/投影 → 缓存(ETag/条件请求/Cache-Control) → 错误(ProblemDetails) → 可观测/配额/变更管理。
-
关键语义:
If-Match
强比较(强 ETag),不匹配 → 412;If-None-Match
弱比较(含*
),命中 → 304;- 304 必带可变元数据(
ETag/Cache-Control/Vary/...
)。
-
三件套:OpenAPI Lint(Spectral)🧪+ 门户 OperationFilter 📘 + 兼容性测试集(Newman/BDN)🧰。
-
亮点:列表弱 ETag 以“本页 RowVersion 摘要”生成 📈;
Vary
合并追加(含Accept
&Accept-Encoding
);绝对Link
由 LinkGenerator 生成(适配反向代理/CDN)。
合约基线鸟瞰
1. 背景与目标 🎯
- 痛点:接口风格分裂、无上限分页、过取/欠取、弱缓存/无条件请求、错误契约不统一。
- 目标:在 ABP vNext 体系沉淀可执行的 HTTP 合约基线(可被 Lint/拦截/文档化/测试守门);以游标分页+投影降带宽,以ETag+条件请求降尾延迟与负载。
2. 适用范围 📐
- 适用:RESTful/HTTP API(Controller、Minimal API、OData 网关)。
- 非目标:领域建模与 GraphQL;本文专注HTTP 契约层。
3. 资源命名与版本治理(ABP 落地) 🧩
- 命名:复数资源、层级:
/orders/{id}/items
;动作用副资源/作业资源(/exports
、/jobs
)。 - 版本:ABP 集成 ASP.NET API Versioning 与 ApiExplorer/Swagger 分组。
// ApiContractBaselineModule.ConfigureServices(...)
context.Services.AddAbpApiVersioning(o =>
{o.ReportApiVersions = true;o.AssumeDefaultVersionWhenUnspecified = true;
});
- 弃用与下线:文档与响应宣布
Deprecation/Sunset
;门户提供时间线。 🗓️
4. 查询契约:分页 / 过滤 / 排序 / 投影 🔎
4.1 分页(游标优先) 🧭
输出
Link
为绝对 URI,由 LinkGenerator 生成,适配代理/CDN。
4.2 排序(ThenBy 链式、字段白名单) 📑
public static class QueryableSortExtensions
{private static readonly HashSet<string> Allowed = new(StringComparer.OrdinalIgnoreCase){ "id", "name", "price", "createdAt" };public static IQueryable<Product> ApplySort(this IQueryable<Product> q, string? sort){if (string.IsNullOrWhiteSpace(sort)) return q.OrderByDescending(x => x.Id);IOrderedQueryable<Product>? ordered = null;foreach (var token in sort.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)){var desc = token.StartsWith("-");var key = desc ? token[1..] : token;if (!Allowed.Contains(key)) continue;ordered = (ordered, key, desc) switch{(null, "price", false) => q.OrderBy(x => x.Price),(null, "price", true) => q.OrderByDescending(x => x.Price),(null, "createdAt", false) => q.OrderBy(x => x.CreatedAt),(null, "createdAt", true) => q.OrderByDescending(x => x.CreatedAt),(null, "name", false) => q.OrderBy(x => x.Name),(null, "name", true) => q.OrderByDescending(x => x.Name),(null, _, false) => q.OrderBy(x => x.Id),(null, _, true) => q.OrderByDescending(x => x.Id),(not null, "price", false) => ordered!.ThenBy(x => x.Price),(not null, "price", true) => ordered!.ThenByDescending(x => x.Price),(not null, "createdAt", false) => ordered!.ThenBy(x => x.CreatedAt),(not null, "createdAt", true) => ordered!.ThenByDescending(x => x.CreatedAt),(not null, "name", false) => ordered!.ThenBy(x => x.Name),(not null, "name", true) => ordered!.ThenByDescending(x => x.Name),_ => ordered!};}return ordered ?? q.OrderByDescending(x => x.Id);}
}
4.3 投影(fields) ✂️
public sealed record QuerySpec(string? Cursor = null,int Limit = 50,string? Sort = null,string? Fields = null // "id,name,price"
)
{public int Take => Math.Clamp(Limit, 1, 200);
}
生产建议:在 IQueryable 层
Select
到瘦 DTO(或 AutoMapperProjectTo
),避免过取与二次分配。
4.4 OData(可选,限幅一致) 🧱
services.AddControllers().AddOData(opt => opt.Select().OrderBy().Count().SetMaxTop(200));[EnableQuery(MaxTop = 200,MaxExpansionDepth = 2,AllowedQueryOptions =AllowedQueryOptions.Select |AllowedQueryOptions.Filter |AllowedQueryOptions.OrderBy |AllowedQueryOptions.Top |AllowedQueryOptions.Skip |AllowedQueryOptions.Count)]
public IQueryable<ProductDto> Get() => _svc.Query();
5. 缓存契约:ETag / 条件请求 / Cache-Control ⚡
5.1 强/弱 ETag 与比较语义 🧠
- 强 ETag:Base64(RowVersion),用于
If-Match
(强比较)。 - 弱 ETag:
W/"..."
,建议用于列表/聚合,If-None-Match
(弱比较)可命中。 - 304:必须携带可变元数据(如
ETag/Cache-Control/Vary/...
)。
5.2 统一 ETag 生成与 Vary
合并 🧩
public interface IETagProvider
{string CreateStrongEtag(byte[] rowVersion);string CreateWeakEtag(string composite); // e.g., page RowVersions
}public sealed class DefaultETagProvider : IETagProvider
{public string CreateStrongEtag(byte[] rowVersion)=> $"\"{Convert.ToBase64String(rowVersion)}\""; // 强 ETag(无 W/)public string CreateWeakEtag(string composite){using var sha1 = System.Security.Cryptography.SHA1.Create();var hash = sha1.ComputeHash(System.Text.Encoding.UTF8.GetBytes(composite));return $"W/\"{Convert.ToBase64String(hash)}\"";}
}static void AddVary(HttpResponse res, params string[] tokens)
{var existing = res.Headers[HeaderNames.Vary].ToString().Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries).ToHashSet(StringComparer.OrdinalIgnoreCase);foreach (var t in tokens) existing.Add(t);res.Headers[HeaderNames.Vary] = string.Join(", ", existing);
}
5.3 条件 GET/HEAD(含 *
)与并发写 🔒
using Microsoft.Net.Http.Headers;[ApiController, Route("api/products")]
public class ProductsController : ControllerBase
{private readonly AppDbContext _db;private readonly IETagProvider _etag;private readonly LinkGenerator _links;public ProductsController(AppDbContext db, IETagProvider etag, LinkGenerator links){ _db = db; _etag = etag; _links = links; }[HttpGet("{id:long}")][HttpHead("{id:long}")] // HEAD 支持:与 GET 共用条件判定与头public async Task<IActionResult> GetById(long id, CancellationToken ct){var e = await _db.Products.AsNoTracking().Select(x => new { x.Id, x.Name, x.Price, x.UpdatedAt, x.RowVersion }).FirstOrDefaultAsync(x => x.Id == id, ct);if (e is null) return NotFound();var strong = new EntityTagHeaderValue(_etag.CreateStrongEtag(e.RowVersion));// If-None-Match: * 或 标签列表(弱比较)if (Request.Headers.TryGetValue(HeaderNames.IfNoneMatch, out var inm)){if (inm.Count == 1 && inm[0].Trim() == "*")return NotModifiedWithMetadata(strong); // 资源存在即命中if (EntityTagHeaderValue.TryParseList(inm, out var etags) &&etags.Any(tag => EntityTagHeaderValue.Compare(tag, strong, useStrongComparison: false)))return NotModifiedWithMetadata(strong);}WithCacheMetadata(Response, strong, maxAgeSeconds: 60);return Ok(e);}[HttpPut("{id:long}")]public async Task<IActionResult> Update(long id, [FromBody] UpdateProduct dto, CancellationToken ct){var entity = await _db.Products.FirstOrDefaultAsync(x => x.Id == id, ct);if (entity is null) return NotFound();var current = new EntityTagHeaderValue(_etag.CreateStrongEtag(entity.RowVersion));// If-Match(强比较)保护并发if (!Request.Headers.TryGetValue(HeaderNames.IfMatch, out var ifm) ||!EntityTagHeaderValue.TryParseList(ifm, out var candidates) ||!candidates.Any(tag => EntityTagHeaderValue.Compare(tag, current, useStrongComparison: true))){return StatusCode(StatusCodes.Status412PreconditionFailed,new ProblemDetails { Title = "Precondition Failed", Status = 412, Detail = "ETag mismatch." });}// ... mutate entity & SaveChangesawait _db.SaveChangesAsync(ct);return NoContent();}// ---- helpers ----IActionResult NotModifiedWithMetadata(EntityTagHeaderValue etag){WithCacheMetadata(Response, etag, maxAgeSeconds: 60);return StatusCode(StatusCodes.Status304NotModified);}static void WithCacheMetadata(HttpResponse res, EntityTagHeaderValue etag, int maxAgeSeconds){var headers = res.GetTypedHeaders();headers.ETag = etag;headers.CacheControl = new CacheControlHeaderValue { Private = true, MaxAge = TimeSpan.FromSeconds(maxAgeSeconds) };AddVary(res, "Accept", "Accept-Encoding"); // 合并追加// Date 由服务器自动写入}
}
反向代理提示:在
Program.cs
配置app.UseForwardedHeaders()
以尊重X-Forwarded-*
,并在 Kestrel/反代层正确设置可信代理。
5.4 列表端点:弱 ETag(本页摘要)与 304 元数据、绝对 Link
🔁
[HttpGet]
public async Task<IActionResult> List([FromQuery] QuerySpec q, CancellationToken ct)
{var baseQuery = _db.Products.AsNoTracking();var items = await baseQuery.ApplySort(q.Sort).Take(q.Take + 1).ToListAsync(ct);var hasMore = items.Count > q.Take;var page = hasMore ? items.Take(q.Take).ToList() : items;var nextCursor = hasMore ? items.Last().Id.ToString() : null;// 弱 ETag:仅反映**本页** RowVersion 摘要(提升命中率)var rvConcat = page.Count == 0? "empty" // 空页稳定 ETag: string.Join('|', page.Select(p => Convert.ToBase64String(p.RowVersion)));var weak = new EntityTagHeaderValue(_etag.CreateWeakEtag(rvConcat));// If-None-Match: * 或 标签列表(弱比较)if (Request.Headers.TryGetValue(HeaderNames.IfNoneMatch, out var inm)){if (inm.Count == 1 && inm[0].Trim() == "*"){WithCacheMetadata(Response, weak, 30);return StatusCode(StatusCodes.Status304NotModified);}if (EntityTagHeaderValue.TryParseList(inm, out var etags) &&etags.Any(tag => EntityTagHeaderValue.Compare(tag, weak, useStrongComparison: false))){WithCacheMetadata(Response, weak, 30);return StatusCode(StatusCodes.Status304NotModified);}}WithCacheMetadata(Response, weak, 30);if (nextCursor != null){// 使用 LinkGenerator 生成绝对 URL(适配反代)var nextUrl = _links.GetUriByAction(httpContext: HttpContext,action: nameof(List),controller: "Products",values: new { cursor = nextCursor, limit = q.Limit });Response.Headers.Append(HeaderNames.Link, $"<{nextUrl}>; rel=\"next\"");}return Ok(new { data = page, nextCursor });
}
6. 错误与一致性:ProblemDetails + Trace Context 🆘
builder.Services.AddProblemDetails();app.UseForwardedHeaders(); // 反向代理/负载均衡场景建议启用
app.Use(async (ctx, next) =>
{var id = System.Diagnostics.Activity.Current?.Id;if (!string.IsNullOrEmpty(id))ctx.Response.Headers["traceparent"] = id; // 进入时写await next();
});
app.UseExceptionHandler(); // 未处理异常 → problem+json
7. 安全与最小暴露 🛡️
- 仅绑定 DTO 字段;输出投影最小化、敏感字段脱敏;
- 速率限制头
RateLimit/RateLimit-Policy
(草案)可选,不做强制门禁; - OData:限制
AllowedQueryOptions/MaxTop/MaxExpansionDepth
,必要时限制表达式复杂度。
8. 可观测与 SLO 🔭
- 指标:
304_ratio
、avg_body_bytes
、p95_latency
、etag_missing_total
、projection_hit_ratio
; - 日志:记录分页/过滤/排序/投影与条件请求命中/落空原因;
- 告警:分页无上限、投影缺失导致响应过大、ETag 漏配、条件请求无效。
9. “契约校验器”与门禁(Spectral + 代码级) 🧪
9.1 OpenAPI Lint(规则节选)
extends: "spectral:oas"
rules:get-must-document-etag:description: "GET 响应需声明 ETag 或 304 分支"given: "$.paths[*][get].responses"then:function: schemafunctionOptions:schema:type: objectanyOf:- required: ["304"]- properties:"200":properties:headers:type: objectpatternProperties:"(?i)^etag$": {}write-must-require-if-match:description: "PUT/PATCH/DELETE 需文档化 If-Match 请求头"given: "$.paths[*][put,patch,delete]"then:field: parametersfunction: truthyproblem-details-on-4xx-5xx:description: "4xx/5xx 必须提供 application/problem+json"given: "$.paths[*][*].responses[?(@key.match(/^[45]\\d\\d$/))].content"then:field: application/problem+jsonfunction: truthy
9.2 CI/门禁流水线(Mermaid)
10. 开发者门户(Swagger/Redoc) 📘
- OperationFilter:为 GET 增加
If-None-Match
参数、304
头;为PUT/PATCH/DELETE
增加If-Match
参数与412
说明;统一ProblemDetails
Schema。
using Microsoft.OpenApi.Models;
using Swashbuckle.AspNetCore.SwaggerGen;public class ETagOperationFilter : IOperationFilter
{public void Apply(OpenApiOperation op, OperationFilterContext ctx){var method = ctx.ApiDescription.HttpMethod?.ToUpperInvariant();op.Parameters ??= new List<OpenApiParameter>();if (method == "GET"){op.Parameters.Add(new OpenApiParameter{Name = "If-None-Match", In = ParameterLocation.Header,Schema = new OpenApiSchema{ Type = "string" },Description = "ETag for conditional GET (supports weak compare and *)"});op.Responses.TryAdd("304", new OpenApiResponse{Description = "Not Modified",Headers = new Dictionary<string, OpenApiHeader>{["ETag"] = new(){ Schema = new() { Type = "string" } },["Cache-Control"] = new(){ Schema = new() { Type = "string" } },["Vary"] = new(){ Schema = new() { Type = "string" } }}});}if (method is "PUT" or "PATCH" or "DELETE"){op.Parameters.Add(new OpenApiParameter{Name = "If-Match", In = ParameterLocation.Header, Required = true,Schema = new OpenApiSchema{ Type = "string" },Description = "Strong ETag required (strong comparison)"});op.Responses.TryAdd("412", new OpenApiResponse{Description = "Precondition Failed (ProblemDetails)",Content = new Dictionary<string, OpenApiMediaType>{["application/problem+json"] = new() {Schema = new OpenApiSchema {Reference = new OpenApiReference{ Id="ProblemDetails", Type=ReferenceType.Schema } } }}});}}
}
若需要在 Swagger 中显式
ProblemDetails
Schema,可在AddSwaggerGen
中MapType<ProblemDetails>(_ => new OpenApiSchema{ ... })
或引入共享 Schema。
11. 兼容性测试集(Newman/Playwright/BDN) 🧪🧪
- 条件 GET:首 GET 取 ETag →
If-None-Match
(含*
)→ 304;断言ETag/Cache-Control/Vary
。 - 并发写:用旧强 ETag → 412;用新强 ETag → 204/200。
- 游标分页:极值/去重/不回退;
Link
绝对 URI 校验。 - 投影:
fields
不含字段不返回且不报错。 - ProblemDetails:4xx/5xx 均为
application/problem+json
。
# 条件 GET + 304
etag=$(curl -sI http://localhost:5000/api/products/1 | awk -F': ' '/^ETag:/{print $2}' | tr -d '\r')
curl -i -H "If-None-Match: $etag" http://localhost:5000/api/products/1
curl -i -H "If-None-Match: *" http://localhost:5000/api/products/1 # 通配命中# 并发写 412
curl -i -X PUT -H "If-Match: \"stale-etag\"" -H "Content-Type: application/json" \-d '{"name":"N","price":1}' http://localhost:5000/api/products/1
12. ABP vNext 工程化落地 🧱
- 配置项(
appsettings.json
):分页上限/字段白名单/缓存策略/OData QueryOptions; - CI 门禁:Spectral + 单测 + 回归集;
- 门户:定制 Swagger UI 页签展示契约基线、弃用时间线与复制片段。
- 反向代理:
UseForwardedHeaders
+ 可信代理列表 +LinkGenerator
生成绝对 URI。
13. 路线图 🗺️
常见误区 ✅
If-None-Match: *
已覆盖到详情与列表(GET/HEAD 命中 304)。- 列表弱 ETag 使用“本页摘要”,命中率更高;空页稳定弱 ETag。
Vary
合并追加(默认含Accept
,Accept-Encoding
)。- 绝对
Link
由 LinkGenerator 生成(适配反代)。 - OData
MaxTop
全局=局部一致。 - 坚持:
If-Match
强比较、If-None-Match
弱比较、304 必带元数据。
可复现指南(最小步骤) 🧰
dotnet new webapi -n ApiBaselineDemo
cd ApiBaselineDemodotnet add package Swashbuckle.AspNetCore
dotnet add package Asp.Versioning.Mvc
dotnet add package Asp.Versioning.Mvc.ApiExplorer
dotnet add package Microsoft.AspNetCore.OData# 将本文中的:
# - IETagProvider/DefaultETagProvider/AddVary/WithCacheMetadata
# - QuerySpec/QueryableSortExtensions
# - ProductsController(含 If-None-Match:*、本页弱 ETag、LinkGenerator 绝对 Link、HEAD 支持)
# - OperationFilter/ProblemDetails 中间件
# 复制进项目,net8.0 编译运行
dotnet run