ASP.NET Web API 核心实战指南
ASP.NET Core 是一款由微软开发的高性能、开源且跨平台的框架,广泛应用于构建现代 Web 应用程序。在工业物联网(IIoT)与企业级开发中,Web API 作为数据交互的核心,其稳定性和性能至关重要。
ASP.NET Web API 开发全景实战
ASP.NET Core Web API 是微软在 .NET 平台上构建 RESTful HTTP 服务的主流框架。本章从请求管道的底层机制出发,逐层拆解路由、控制器、模型绑定、中间件、DI、安全、可观测性、测试到部署的全链路知识体系,所有示例基于 .NET 8。
核心架构与请求处理管道
ASP.NET Core 的每一个 HTTP 请求都经过同一条中间件管道(Middleware Pipeline),这是整个框架的核心骨架。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
| HTTP Request
│
▼
┌─────────────────────────────────────────┐
│ Middleware Pipeline │
│ │
│ ExceptionHandler → HSTS → HTTPS重定向 │
│ → StaticFiles → Routing → CORS │
│ → RateLimiter → Authentication │
│ → Authorization → [EndpointMiddleware] │
│ │ │
│ ▼ │
│ ┌─────────────────────┐ │
│ │ Filter Pipeline │ │
│ │ Auth → Resource │ │
│ │ → Action → Result │ │
│ │ → Exception │ │
│ └────────┬────────────┘ │
│ ▼ │
│ Controller / Action │
└─────────────────────────────────────────┘
│
▼
HTTP Response
|
Program.cs 最简骨架:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
| var builder = WebApplication.CreateBuilder(args);
// 1. 注册服务(DI 容器)
builder.Services.AddControllers();
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen();
var app = builder.Build();
// 2. 配置中间件管道(顺序至关重要)
if (app.Environment.IsDevelopment())
{
app.UseSwagger();
app.UseSwaggerUI();
}
app.UseHttpsRedirection();
app.UseCors();
app.UseAuthentication();
app.UseAuthorization();
app.MapControllers();
app.Run();
|
关键原则:UseAuthentication() 必须在 UseAuthorization() 之前;UseRouting() 在 .NET 6+ 中由 MapControllers() 隐式调用,通常无需手动添加。
路由系统详解
约定路由 (Conventional Routing)
在 Program.cs 中统一配置路由模板,适合 MVC 风格项目:
1
2
3
| app.MapControllerRoute(
name: "default",
pattern: "{controller=Home}/{action=Index}/{id?}");
|
Web API 项目一般不使用约定路由,更推荐特性路由。
特性路由 (Attribute Routing)
直接在 Controller 或 Action 上标注路由,语义清晰:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
| [ApiController]
[Route("api/v1/[controller]")] // [controller] 自动替换为 "products"
public class ProductsController : ControllerBase
{
// GET api/v1/products
[HttpGet]
public IActionResult GetAll() => Ok();
// GET api/v1/products/42
[HttpGet("{id:int}")]
public IActionResult GetById(int id) => Ok();
// POST api/v1/products
[HttpPost]
public IActionResult Create([FromBody] CreateProductDto dto) => CreatedAtAction(nameof(GetById), new { id = 1 }, dto);
// PUT api/v1/products/42
[HttpPut("{id:int}")]
public IActionResult Update(int id, [FromBody] UpdateProductDto dto) => NoContent();
// DELETE api/v1/products/42
[HttpDelete("{id:int}")]
public IActionResult Delete(int id) => NoContent();
}
|
路由约束与正则匹配
路由约束在模板中内联,防止无效参数进入 Action:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
| // 内置约束
[HttpGet("{id:int}")] // 必须是整数
[HttpGet("{name:alpha}")] // 只含字母
[HttpGet("{id:guid}")] // GUID 格式
[HttpGet("{age:range(1,120)}")] // 数值范围
[HttpGet("{slug:minlength(3)}")] // 最短长度
// 自定义约束:实现 IRouteConstraint
public class EvenNumberConstraint : IRouteConstraint
{
public bool Match(HttpContext? httpContext, IRouter? route,
string routeKey, RouteValueDictionary values, RouteDirection routeDirection)
{
if (values.TryGetValue(routeKey, out var value)
&& int.TryParse(value?.ToString(), out int number))
{
return number % 2 == 0;
}
return false;
}
}
// 注册自定义约束
builder.Services.Configure<RouteOptions>(options =>
options.ConstraintMap.Add("even", typeof(EvenNumberConstraint)));
// 使用
[HttpGet("{id:even}")]
public IActionResult GetEven(int id) => Ok(id);
|
控制器与 Action 生命周期
ApiController 与 ControllerBase 深度解析
[ApiController] 特性为控制器启用一组 Web API 专属行为:
| 行为 |
说明 |
| 自动模型验证 |
ModelState 无效时自动返回 400 ValidationProblemDetails |
| 参数来源推断 |
复杂类型自动推断为 [FromBody],简单类型为 [FromRoute]/[FromQuery] |
| 禁用视图查找 |
返回 null 不会尝试渲染视图 |
| ProblemDetails 错误 |
4xx/5xx 状态码使用标准 Problem Details 格式 |
1
2
3
4
5
6
7
8
9
10
| [ApiController]
[Route("api/[controller]")]
public class OrdersController : ControllerBase
{
private readonly IOrderService _orderService;
// 构造函数注入
public OrdersController(IOrderService orderService)
=> _orderService = orderService;
}
|
ActionResult 多样化返回类型对比
推荐使用 ActionResult<T> 或 IActionResult,配合语义化帮助方法:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
| // ActionResult<T>:同时支持强类型和 HTTP 响应
[HttpGet("{id}")]
public async Task<ActionResult<ProductDto>> GetById(int id)
{
var product = await _service.FindAsync(id);
if (product is null)
return NotFound(new { message = $"Product {id} not found" });
return Ok(product); // 200 + JSON body
}
// 常用帮助方法速查
return Ok(data); // 200
return Created("/api/items/1", data); // 201
return CreatedAtAction(nameof(GetById), // 201 + Location 头
new { id = newItem.Id }, newItem);
return Accepted(); // 202
return NoContent(); // 204
return BadRequest(ModelState); // 400
return Unauthorized(); // 401
return Forbid(); // 403
return NotFound(); // 404
return Conflict(); // 409
return UnprocessableEntity(ModelState); // 422
return StatusCode(503, "Service unavailable"); // 任意状态码
// .NET 7+ TypedResults(Minimal API 风格,也可在 Controller 使用)
return TypedResults.Ok(product);
return TypedResults.NotFound();
|
数据层:模型绑定与模型验证
模型参数来源与推断机制
1
2
3
4
5
6
7
8
9
10
11
| [HttpPost("search")]
public IActionResult Search(
[FromQuery] string keyword, // ?keyword=laptop
[FromQuery] int page = 1, // ?page=2
[FromHeader(Name = "X-Api-Key")] string apiKey, // 请求头
[FromBody] SearchFilter filter, // 请求体 JSON
[FromRoute] int categoryId, // 路由段 /categories/5/search
[FromServices] ILogger<...> logger) // 直接从 DI 注入
{
// ...
}
|
[FromBody] 一个 Action 只能用一次(HTTP 请求体只有一个)。需要同时接收多个复杂对象时,将它们包装成一个 DTO。
DataAnnotations 声明式验证
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
| public class CreateProductDto
{
[Required(ErrorMessage = "名称不能为空")]
[StringLength(100, MinimumLength = 2)]
public string Name { get; set; } = default!;
[Range(0.01, 999999.99, ErrorMessage = "价格必须在 0.01 到 999999.99 之间")]
public decimal Price { get; set; }
[Required]
[RegularExpression(@"^[A-Z]{2}\d{6}$", ErrorMessage = "SKU 格式不正确")]
public string Sku { get; set; } = default!;
[Url]
public string? ImageUrl { get; set; }
[EmailAddress]
public string? ContactEmail { get; set; }
}
|
[ApiController] 自动处理验证失败,无需手动检查 ModelState.IsValid。如需手动处理或自定义响应:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
| // 禁用自动验证响应,手动控制
[HttpPost]
public IActionResult Create([FromBody] CreateProductDto dto)
{
if (!ModelState.IsValid)
{
// 自定义错误格式
var errors = ModelState
.Where(x => x.Value?.Errors.Count > 0)
.ToDictionary(
x => x.Key,
x => x.Value!.Errors.Select(e => e.ErrorMessage).ToArray());
return BadRequest(new { errors });
}
// ...
}
|
FluentValidation 高级验证集成
FluentValidation 提供比 DataAnnotations 更强大的验证能力,尤其适合复杂业务规则:
1
| dotnet add package FluentValidation.AspNetCore
|
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
| // 定义验证器
public class CreateProductValidator : AbstractValidator<CreateProductDto>
{
private readonly IProductRepository _repo;
public CreateProductValidator(IProductRepository repo)
{
_repo = repo;
RuleFor(x => x.Name)
.NotEmpty().WithMessage("名称不能为空")
.Length(2, 100)
.MustAsync(async (name, ct) => !await _repo.ExistsAsync(name))
.WithMessage("该名称已存在");
RuleFor(x => x.Price)
.GreaterThan(0).WithMessage("价格必须大于 0")
.LessThanOrEqualTo(999999.99m);
RuleFor(x => x.CategoryId)
.NotEmpty()
.MustAsync(async (id, ct) => await _repo.CategoryExistsAsync(id))
.WithMessage("分类不存在");
// 条件验证
When(x => x.HasDiscount, () =>
{
RuleFor(x => x.DiscountPercent)
.InclusiveBetween(1, 99);
});
}
}
// 注册(自动扫描程序集中所有 AbstractValidator<T>)
builder.Services.AddFluentValidationAutoValidation();
builder.Services.AddValidatorsFromAssemblyContaining<CreateProductValidator>();
|
内容协商与响应格式化 (JSON/XML)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
| // 配置 JSON 序列化选项
builder.Services.AddControllers()
.AddJsonOptions(options =>
{
// 驼峰命名(默认)
options.JsonSerializerOptions.PropertyNamingPolicy = JsonNamingPolicy.CamelCase;
// 忽略 null 值字段
options.JsonSerializerOptions.DefaultIgnoreCondition =
JsonIgnoreCondition.WhenWritingNull;
// 枚举序列化为字符串
options.JsonSerializerOptions.Converters.Add(new JsonStringEnumConverter());
// 允许尾随逗号(宽松模式)
options.JsonSerializerOptions.AllowTrailingCommas = true;
});
// 同时支持 XML 格式(内容协商:客户端通过 Accept 头选择)
builder.Services.AddControllers()
.AddXmlSerializerFormatters();
|
内容协商:客户端发送 Accept: application/xml,服务端自动返回 XML;发送 Accept: application/json 或无 Accept 头,返回 JSON。
中间件管道 (Middleware Pipeline) 机制
标准中间件注册顺序规范
顺序决定行为,以下是推荐的标准顺序:
1
2
3
4
5
6
7
8
9
10
11
12
| var app = builder.Build();
app.UseExceptionHandler("/error"); // 1. 最外层:捕获所有未处理异常
app.UseHsts(); // 2. HSTS 响应头(生产环境)
app.UseHttpsRedirection(); // 3. HTTP → HTTPS 重定向
app.UseStaticFiles(); // 4. 静态文件(wwwroot)
app.UseRouting(); // 5. 路由匹配(.NET 6+ 通常隐式)
app.UseCors("MyPolicy"); // 6. CORS(路由之后,认证之前)
app.UseRateLimiter(); // 7. 限流
app.UseAuthentication(); // 8. 认证(必须在授权之前)
app.UseAuthorization(); // 9. 授权
app.MapControllers(); // 10. 终端中间件:映射控制器
|
自定义中间件开发实战
方式一:基于约定的类(推荐)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
| public class RequestTimingMiddleware
{
private readonly RequestDelegate _next;
private readonly ILogger<RequestTimingMiddleware> _logger;
public RequestTimingMiddleware(RequestDelegate next,
ILogger<RequestTimingMiddleware> logger)
{
_next = next;
_logger = logger;
}
public async Task InvokeAsync(HttpContext context)
{
var sw = Stopwatch.StartNew();
try
{
await _next(context); // 调用管道中的下一个中间件
}
finally
{
sw.Stop();
_logger.LogInformation(
"请求 {Method} {Path} 耗时 {ElapsedMs}ms,状态码 {StatusCode}",
context.Request.Method,
context.Request.Path,
sw.ElapsedMilliseconds,
context.Response.StatusCode);
}
}
}
// 注册扩展方法(可选,便于链式调用)
public static class RequestTimingMiddlewareExtensions
{
public static IApplicationBuilder UseRequestTiming(
this IApplicationBuilder builder)
=> builder.UseMiddleware<RequestTimingMiddleware>();
}
// 使用
app.UseRequestTiming();
|
方式二:内联 Lambda(适合简单逻辑)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
| // Use:有下一个中间件
app.Use(async (context, next) =>
{
context.Response.Headers.Add("X-Frame-Options", "DENY");
await next(context);
});
// Run:终端,不调用 next
app.Run(async context =>
{
await context.Response.WriteAsync("Fallback response");
});
// Map:路径分支
app.Map("/health-simple", branch =>
{
branch.Run(async ctx =>
await ctx.Response.WriteAsync("OK"));
});
|
依赖注入 (DI) 生命周期与模式
三大服务生存期详解 (Singleton/Scoped/Transient)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
| // Singleton:整个应用生命周期只有一个实例
builder.Services.AddSingleton<IConfigService, ConfigService>();
// Scoped:每个 HTTP 请求一个实例(最常用于 DbContext、业务服务)
builder.Services.AddScoped<IProductService, ProductService>();
builder.Services.AddScoped<IProductRepository, ProductRepository>();
// Transient:每次从容器获取都创建新实例(适合无状态的轻量服务)
builder.Services.AddTransient<IEmailSender, SmtpEmailSender>();
// 注册接口的多个实现
builder.Services.AddScoped<IPaymentGateway, AlipayGateway>();
builder.Services.AddScoped<IPaymentGateway, WechatPayGateway>();
// 注入时使用 IEnumerable<IPaymentGateway>
// 工厂注册
builder.Services.AddScoped<INotificationService>(sp =>
{
var config = sp.GetRequiredService<IConfiguration>();
return config["NotificationType"] == "sms"
? new SmsNotificationService(sp.GetRequiredService<ISmsClient>())
: new EmailNotificationService(sp.GetRequiredService<IEmailSender>());
});
|
注意:不要在 Singleton 中注入 Scoped 服务,这会导致 Scoped 服务被”捕获”(Captive Dependency),等效于 Singleton 生命周期,可能引发并发问题。
强类型配置管理:Options 模式与热重载
Options 模式将配置强类型绑定,是 IConfiguration 的进阶用法:
1
2
3
4
5
6
7
8
9
| // appsettings.json
{
"Jwt": {
"Issuer": "https://myapi.com",
"Audience": "myapi-clients",
"SecretKey": "super-secret-key-at-least-32-chars",
"ExpirationMinutes": 60
}
}
|
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
| // 配置 POCO
public class JwtOptions
{
public const string SectionName = "Jwt";
public string Issuer { get; set; } = default!;
public string Audience { get; set; } = default!;
public string SecretKey { get; set; } = default!;
public int ExpirationMinutes { get; set; } = 60;
}
// 注册并绑定(同时启用数据注解验证)
builder.Services
.AddOptions<JwtOptions>()
.BindConfiguration(JwtOptions.SectionName)
.ValidateDataAnnotations()
.ValidateOnStart(); // 应用启动时立即验证,配置错误快速失败
// 三种注入方式:
// IOptions<T>:单例,不感知配置变化
// IOptionsSnapshot<T>:Scoped,每请求重新读取(支持热重载)
// IOptionsMonitor<T>:单例,通过 OnChange 回调感知变化
public class TokenService
{
private readonly JwtOptions _jwt;
public TokenService(IOptions<JwtOptions> options)
=> _jwt = options.Value;
public string GenerateToken(string userId)
{
var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_jwt.SecretKey));
// ...
}
}
|
过滤器管道 (Filter Pipeline) 全析
过滤器类型与管道级联顺序
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
| 请求进入
│
▼
[Authorization Filter] ← 最先执行,失败则短路
│
▼
[Resource Filter] - Before
│
▼
[Model Binding]
│
▼
[Action Filter] - OnActionExecuting
│
▼
[Action 方法执行]
│
▼
[Action Filter] - OnActionExecuted
│
▼
[Exception Filter] ← 捕获 Action 执行中的异常
│
▼
[Result Filter] - OnResultExecuting
│
▼
[Result 执行(序列化响应)]
│
▼
[Result Filter] - OnResultExecuted
│
▼
[Resource Filter] - After
│
▼
响应返回
|
自定义 Action / Exception 过滤器示例
Action Filter 示例:自动记录操作日志
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
| public class AuditLogFilter : IAsyncActionFilter
{
private readonly IAuditLogService _auditLog;
public AuditLogFilter(IAuditLogService auditLog)
=> _auditLog = auditLog;
public async Task OnActionExecutionAsync(
ActionExecutingContext context, ActionExecutionDelegate next)
{
var actionName = context.ActionDescriptor.DisplayName;
var user = context.HttpContext.User.Identity?.Name ?? "anonymous";
// Action 执行前
var startTime = DateTime.UtcNow;
var resultContext = await next(); // 执行 Action
// Action 执行后
var success = resultContext.Exception is null;
await _auditLog.RecordAsync(new AuditEntry
{
Action = actionName,
User = user,
ExecutedAt = startTime,
DurationMs = (DateTime.UtcNow - startTime).TotalMilliseconds,
Success = success
});
}
}
// 注册为全局过滤器
builder.Services.AddScoped<AuditLogFilter>();
builder.Services.AddControllers(options =>
options.Filters.AddService<AuditLogFilter>());
// 或在特定 Controller/Action 上使用 [ServiceFilter]
[ServiceFilter(typeof(AuditLogFilter))]
public class OrdersController : ControllerBase { }
|
Exception Filter 示例:统一异常响应
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
| public class ApiExceptionFilter : IExceptionFilter
{
private readonly ILogger<ApiExceptionFilter> _logger;
public ApiExceptionFilter(ILogger<ApiExceptionFilter> logger)
=> _logger = logger;
public void OnException(ExceptionContext context)
{
_logger.LogError(context.Exception, "未处理异常");
var (statusCode, title) = context.Exception switch
{
NotFoundException => (404, "资源未找到"),
ValidationException => (422, "业务验证失败"),
UnauthorizedAccessException => (403, "无访问权限"),
_ => (500, "服务器内部错误")
};
context.Result = new ObjectResult(new ProblemDetails
{
Status = statusCode,
Title = title,
Detail = context.Exception.Message
})
{ StatusCode = statusCode };
context.ExceptionHandled = true;
}
}
|
全局异常处理机制与 Problem Details 规范
IExceptionHandler 统一处理器 (.NET 8+)
.NET 8 推荐方式,支持多个处理器按优先级链式处理:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
| public class GlobalExceptionHandler : IExceptionHandler
{
private readonly ILogger<GlobalExceptionHandler> _logger;
public GlobalExceptionHandler(ILogger<GlobalExceptionHandler> logger)
=> _logger = logger;
public async ValueTask<bool> TryHandleAsync(
HttpContext httpContext,
Exception exception,
CancellationToken cancellationToken)
{
_logger.LogError(exception, "未处理异常: {Message}", exception.Message);
var (statusCode, title) = exception switch
{
NotFoundException ex => (StatusCodes.Status404NotFound, "资源未找到"),
ValidationException ex => (StatusCodes.Status422UnprocessableEntity, "验证失败"),
ConflictException ex => (StatusCodes.Status409Conflict, "数据冲突"),
UnauthorizedAccessException => (StatusCodes.Status403Forbidden, "无权限"),
_ => (StatusCodes.Status500InternalServerError, "服务器错误")
};
httpContext.Response.StatusCode = statusCode;
await httpContext.Response.WriteAsJsonAsync(new ProblemDetails
{
Status = statusCode,
Title = title,
Detail = exception.Message,
Instance = httpContext.Request.Path
}, cancellationToken);
return true; // 返回 false 表示此处理器无法处理,交给下一个
}
}
// 注册
builder.Services.AddExceptionHandler<GlobalExceptionHandler>();
builder.Services.AddProblemDetails();
app.UseExceptionHandler();
|
RFC 7807: ProblemDetails 响应标准
ProblemDetails 遵循 RFC 7807,是 HTTP API 错误响应的国际标准格式:
1
2
3
4
5
6
7
8
| {
"type": "https://tools.ietf.org/html/rfc9110#section-15.5.5",
"title": "资源未找到",
"status": 404,
"detail": "Product with ID 42 was not found.",
"instance": "/api/products/42",
"traceId": "00-abc123-def456-00"
}
|
安全基石:身份认证与授权机制
JWT Bearer 认证流集成与配置对比
1
| dotnet add package Microsoft.AspNetCore.Authentication.JwtBearer
|
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
| // Program.cs 注册认证
builder.Services
.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
.AddJwtBearer(options =>
{
options.TokenValidationParameters = new TokenValidationParameters
{
ValidateIssuer = true,
ValidIssuer = builder.Configuration["Jwt:Issuer"],
ValidateAudience = true,
ValidAudience = builder.Configuration["Jwt:Audience"],
ValidateLifetime = true,
ClockSkew = TimeSpan.FromSeconds(30), // 允许的时钟偏差
ValidateIssuerSigningKey = true,
IssuerSigningKey = new SymmetricSecurityKey(
Encoding.UTF8.GetBytes(builder.Configuration["Jwt:SecretKey"]!))
};
// 事件钩子(可选)
options.Events = new JwtBearerEvents
{
OnAuthenticationFailed = context =>
{
if (context.Exception is SecurityTokenExpiredException)
context.Response.Headers.Add("Token-Expired", "true");
return Task.CompletedTask;
}
};
});
|
生成 JWT Token:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
| public class TokenService
{
private readonly JwtOptions _jwt;
public TokenService(IOptions<JwtOptions> options)
=> _jwt = options.Value;
public string GenerateToken(User user)
{
var claims = new[]
{
new Claim(JwtRegisteredClaimNames.Sub, user.Id.ToString()),
new Claim(JwtRegisteredClaimNames.Email, user.Email),
new Claim(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()),
new Claim(ClaimTypes.Role, user.Role),
new Claim("tenant", user.TenantId.ToString())
};
var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_jwt.SecretKey));
var credentials = new SigningCredentials(key, SecurityAlgorithms.HmacSha256);
var token = new JwtSecurityToken(
issuer: _jwt.Issuer,
audience: _jwt.Audience,
claims: claims,
expires: DateTime.UtcNow.AddMinutes(_jwt.ExpirationMinutes),
signingCredentials: credentials);
return new JwtSecurityTokenHandler().WriteToken(token);
}
}
|
基于策略的授权模型 (Policy-Based) 详解
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
| // 注册策略
builder.Services.AddAuthorization(options =>
{
// 基于角色
options.AddPolicy("AdminOnly", policy =>
policy.RequireRole("Admin"));
// 基于 Claim
options.AddPolicy("PremiumUser", policy =>
policy.RequireClaim("subscription", "premium", "enterprise"));
// 复合条件
options.AddPolicy("SeniorManager", policy =>
policy.RequireRole("Manager")
.RequireClaim("experience_years")
.RequireAssertion(ctx =>
int.Parse(ctx.User.FindFirstValue("experience_years") ?? "0") >= 5));
// 自定义 Requirement
options.AddPolicy("MinimumAge18", policy =>
policy.AddRequirements(new MinimumAgeRequirement(18)));
});
// 自定义 Requirement + Handler
public record MinimumAgeRequirement(int MinimumAge) : IAuthorizationRequirement;
public class MinimumAgeHandler : AuthorizationHandler<MinimumAgeRequirement>
{
protected override Task HandleRequirementAsync(
AuthorizationHandlerContext context,
MinimumAgeRequirement requirement)
{
var birthDateClaim = context.User.FindFirst("birthdate");
if (birthDateClaim is not null
&& DateTime.TryParse(birthDateClaim.Value, out var birthDate)
&& DateTime.Today.Year - birthDate.Year >= requirement.MinimumAge)
{
context.Succeed(requirement);
}
return Task.CompletedTask;
}
}
builder.Services.AddScoped<IAuthorizationHandler, MinimumAgeHandler>();
// Controller/Action 上使用
[Authorize] // 仅需登录
[Authorize(Roles = "Admin")] // 角色授权
[Authorize(Policy = "PremiumUser")] // 策略授权
[AllowAnonymous] // 豁免认证
|
跨域解析:CORS 策略配置与安全实践
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
| // 定义 CORS 策略
builder.Services.AddCors(options =>
{
options.AddPolicy("DevelopmentPolicy", policy =>
policy.AllowAnyOrigin()
.AllowAnyMethod()
.AllowAnyHeader());
options.AddPolicy("ProductionPolicy", policy =>
policy.WithOrigins("https://app.example.com", "https://admin.example.com")
.WithMethods("GET", "POST", "PUT", "DELETE")
.WithHeaders("Content-Type", "Authorization", "X-Api-Key")
.AllowCredentials() // 允许 Cookie/认证头(不能与 AllowAnyOrigin 同用)
.SetPreflightMaxAge(TimeSpan.FromMinutes(10))); // 缓存预检结果
});
// 全局应用
app.UseCors("ProductionPolicy");
// 细粒度控制:Controller/Action 级别
[EnableCors("DevelopmentPolicy")] // 覆盖全局策略
[DisableCors] // 完全禁用 CORS
|
API 版本管理 (Versioning) 实现方案
1
2
| dotnet add package Asp.Versioning.Mvc
dotnet add package Asp.Versioning.Mvc.ApiExplorer
|
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
| // 注册
builder.Services.AddApiVersioning(options =>
{
options.DefaultApiVersion = new ApiVersion(1, 0);
options.AssumeDefaultVersionWhenUnspecified = true;
options.ReportApiVersions = true; // 响应头中报告支持的版本
// 版本读取策略(可组合)
options.ApiVersionReader = ApiVersionReader.Combine(
new UrlSegmentApiVersionReader(), // /api/v1/products
new QueryStringApiVersionReader("api-ver"), // ?api-ver=1.0
new HeaderApiVersionReader("X-Api-Version") // X-Api-Version: 1.0
);
})
.AddApiExplorer(options =>
{
options.GroupNameFormat = "'v'VVV";
options.SubstituteApiVersionInUrl = true;
});
// 控制器版本标注
[ApiController]
[ApiVersion("1.0")]
[ApiVersion("2.0")]
[Route("api/v{version:apiVersion}/[controller]")]
public class ProductsController : ControllerBase
{
[HttpGet]
[MapToApiVersion("1.0")]
public IActionResult GetV1() => Ok("Version 1");
[HttpGet]
[MapToApiVersion("2.0")]
public IActionResult GetV2() => Ok("Version 2 with more data");
// 标记废弃
[HttpPost]
[MapToApiVersion("1.0")]
[ApiVersion("1.0", Deprecated = true)]
public IActionResult CreateV1Legacy() => Ok();
}
|
高并发防护:内置限流中间件 (Rate Limiting)
.NET 7+ 内置限流中间件,无需第三方库:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
| builder.Services.AddRateLimiter(options =>
{
options.RejectionStatusCode = StatusCodes.Status429TooManyRequests;
// 固定窗口:每分钟最多 100 次请求
options.AddFixedWindowLimiter("fixed", opt =>
{
opt.Window = TimeSpan.FromMinutes(1);
opt.PermitLimit = 100;
opt.QueueLimit = 10; // 队列中等待的最大请求数
opt.QueueProcessingOrder = QueueProcessingOrder.OldestFirst;
});
// 滑动窗口:更平滑,防止突发
options.AddSlidingWindowLimiter("sliding", opt =>
{
opt.Window = TimeSpan.FromMinutes(1);
opt.PermitLimit = 100;
opt.SegmentsPerWindow = 6; // 分为6个10秒的片段
});
// 令牌桶:允许短时间内突发
options.AddTokenBucketLimiter("token", opt =>
{
opt.TokenLimit = 100;
opt.ReplenishmentPeriod = TimeSpan.FromSeconds(10);
opt.TokensPerPeriod = 20;
opt.AutoReplenishment = true;
});
// 并发限制:同时处理的请求数
options.AddConcurrencyLimiter("concurrent", opt =>
{
opt.PermitLimit = 50;
opt.QueueLimit = 20;
});
// 按用户 IP 动态分区
options.AddPolicy("per-ip", httpContext =>
RateLimitPartition.GetFixedWindowLimiter(
partitionKey: httpContext.Connection.RemoteIpAddress?.ToString() ?? "unknown",
factory: _ => new FixedWindowRateLimiterOptions
{
Window = TimeSpan.FromMinutes(1),
PermitLimit = 60
}));
});
app.UseRateLimiter();
// 在 Action 上使用
[EnableRateLimiting("per-ip")]
[HttpPost("login")]
public IActionResult Login() { ... }
[DisableRateLimiting]
[HttpGet("public-data")]
public IActionResult GetPublicData() { ... }
|
文档化与可调试性:Swagger / OpenAPI 全解
1
| dotnet add package Swashbuckle.AspNetCore
|
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
| builder.Services.AddSwaggerGen(options =>
{
options.SwaggerDoc("v1", new OpenApiInfo
{
Title = "My API",
Version = "v1",
Description = "API 描述文档",
Contact = new OpenApiContact { Name = "开发团队", Email = "dev@example.com" }
});
// 启用 XML 注释(需在 .csproj 中开启 <GenerateDocumentationFile>true</GenerateDocumentationFile>)
var xmlFile = $"{Assembly.GetExecutingAssembly().GetName().Name}.xml";
options.IncludeXmlComments(Path.Combine(AppContext.BaseDirectory, xmlFile));
// 添加 JWT 认证到 Swagger UI
options.AddSecurityDefinition("Bearer", new OpenApiSecurityScheme
{
Name = "Authorization",
Type = SecuritySchemeType.Http,
Scheme = "bearer",
BearerFormat = "JWT",
In = ParameterLocation.Header,
Description = "输入 JWT Token(不需要 Bearer 前缀)"
});
options.AddSecurityRequirement(new OpenApiSecurityRequirement
{
{
new OpenApiSecurityScheme
{
Reference = new OpenApiReference
{ Type = ReferenceType.SecurityScheme, Id = "Bearer" }
},
Array.Empty<string>()
}
});
});
if (app.Environment.IsDevelopment())
{
app.UseSwagger();
app.UseSwaggerUI(c =>
{
c.SwaggerEndpoint("/swagger/v1/swagger.json", "My API V1");
c.RoutePrefix = string.Empty; // Swagger UI 在根路径
});
}
|
控制器上的 XML 注释:
1
2
3
4
5
6
7
8
9
10
11
| /// <summary>
/// 根据 ID 获取产品详情
/// </summary>
/// <param name="id">产品 ID</param>
/// <returns>产品详情</returns>
/// <response code="200">成功返回产品</response>
/// <response code="404">产品不存在</response>
[HttpGet("{id:int}")]
[ProducesResponseType(typeof(ProductDto), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
public async Task<ActionResult<ProductDto>> GetById(int id) { ... }
|
高性能进阶:多级缓存 (IDistributedCache) 指南
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
| // 1. 内存缓存(单机)
builder.Services.AddMemoryCache();
public class ProductService
{
private readonly IMemoryCache _cache;
private readonly IProductRepository _repo;
public async Task<ProductDto?> GetByIdAsync(int id)
{
var cacheKey = $"product:{id}";
if (_cache.TryGetValue(cacheKey, out ProductDto? cached))
return cached;
var product = await _repo.FindByIdAsync(id);
if (product is not null)
{
_cache.Set(cacheKey, product, new MemoryCacheEntryOptions
{
AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(10),
SlidingExpiration = TimeSpan.FromMinutes(2),
Size = 1 // 配合 SizeLimit 使用
});
}
return product;
}
}
// 2. 分布式缓存(Redis)
builder.Services.AddStackExchangeRedisCache(options =>
options.Configuration = builder.Configuration.GetConnectionString("Redis"));
// IDistributedCache 统一接口,后端可换 Redis/SQL Server/Memory
public async Task<string?> GetCachedAsync(string key)
{
var bytes = await _distributedCache.GetAsync(key);
return bytes is null ? null : Encoding.UTF8.GetString(bytes);
}
// 3. 响应缓存(HTTP 缓存)
builder.Services.AddResponseCaching();
app.UseResponseCaching();
[HttpGet]
[ResponseCache(Duration = 60, VaryByQueryKeys = new[] { "page", "size" })]
public IActionResult GetList([FromQuery] int page, [FromQuery] int size) { ... }
// 4. HybridCache(.NET 9,结合内存+分布式)
builder.Services.AddHybridCache();
public async Task<ProductDto?> GetAsync(int id, CancellationToken ct)
=> await _hybridCache.GetOrCreateAsync(
$"product:{id}",
async ct => await _repo.FindByIdAsync(id, ct),
new HybridCacheEntryOptions
{
Expiration = TimeSpan.FromMinutes(10),
LocalCacheExpiration = TimeSpan.FromMinutes(2)
},
cancellationToken: ct);
|
可观测性:结构化日志 (Serilog) 与链路追踪
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
| // 内置 ILogger(依赖注入)
public class ProductsController : ControllerBase
{
private readonly ILogger<ProductsController> _logger;
[HttpGet("{id}")]
public async Task<IActionResult> GetById(int id)
{
// 结构化日志:花括号内是属性名,可被日志平台索引
_logger.LogInformation("查询产品 {ProductId}", id);
var product = await _service.GetByIdAsync(id);
if (product is null)
{
_logger.LogWarning("产品 {ProductId} 不存在", id);
return NotFound();
}
_logger.LogDebug("返回产品 {@Product}", product); // @ 符号序列化整个对象
return Ok(product);
}
}
|
Serilog 集成(推荐):
1
2
3
4
| dotnet add package Serilog.AspNetCore
dotnet add package Serilog.Sinks.Console
dotnet add package Serilog.Sinks.File
dotnet add package Serilog.Sinks.Seq
|
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
| // Program.cs
Log.Logger = new LoggerConfiguration()
.MinimumLevel.Information()
.MinimumLevel.Override("Microsoft", LogEventLevel.Warning)
.MinimumLevel.Override("Microsoft.Hosting.Lifetime", LogEventLevel.Information)
.Enrich.FromLogContext()
.Enrich.WithEnvironmentName()
.Enrich.WithMachineName()
.WriteTo.Console(new JsonFormatter()) // 结构化 JSON 输出
.WriteTo.File("logs/app-.log",
rollingInterval: RollingInterval.Day,
retainedFileCountLimit: 30)
.WriteTo.Seq("http://seq-server:5341")
.CreateLogger();
builder.Host.UseSerilog();
// 中间件:自动记录每个请求
app.UseSerilogRequestLogging(options =>
{
options.MessageTemplate = "{RequestMethod} {RequestPath} 响应 {StatusCode} 耗时 {Elapsed:0.000}ms";
options.EnrichDiagnosticContext = (diagnosticContext, httpContext) =>
{
diagnosticContext.Set("RequestHost", httpContext.Request.Host.Value);
diagnosticContext.Set("UserId", httpContext.User.FindFirstValue(ClaimTypes.NameIdentifier));
};
});
|
服务可靠性:健康检查 (Health Checks) 监控
1
2
3
| dotnet add package AspNetCore.HealthChecks.SqlServer
dotnet add package AspNetCore.HealthChecks.Redis
dotnet add package AspNetCore.HealthChecks.UI
|
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
| builder.Services
.AddHealthChecks()
.AddSqlServer(
connectionString: builder.Configuration.GetConnectionString("Default")!,
name: "sql-server",
tags: new[] { "db", "ready" })
.AddRedis(
redisConnectionString: builder.Configuration.GetConnectionString("Redis")!,
name: "redis",
tags: new[] { "cache", "ready" })
.AddUrlGroup(
uri: new Uri("https://external-api.example.com/ping"),
name: "external-api",
tags: new[] { "external" })
.AddCheck<CustomHealthCheck>("custom", tags: new[] { "ready" });
// 自定义健康检查
public class CustomHealthCheck : IHealthCheck
{
public Task<HealthCheckResult> CheckHealthAsync(
HealthCheckContext context, CancellationToken ct = default)
{
var isHealthy = /* 业务逻辑 */ true;
return Task.FromResult(isHealthy
? HealthCheckResult.Healthy("一切正常")
: HealthCheckResult.Degraded("部分功能受损",
data: new Dictionary<string, object> { { "queue_depth", 10000 } }));
}
}
// 暴露端点(Kubernetes liveness/readiness 探针分离)
app.MapHealthChecks("/health/live", new HealthCheckOptions
{
Predicate = _ => false // 只要应用还在运行就返回 200,不检查依赖
});
app.MapHealthChecks("/health/ready", new HealthCheckOptions
{
Predicate = check => check.Tags.Contains("ready"), // 检查数据库、缓存等
ResponseWriter = UIResponseWriter.WriteHealthCheckUIResponse
});
|
轻量化趋势:Minimal API 构建指南
.NET 6 引入的极简风格,适合微服务、轻量端点:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
| var builder = WebApplication.CreateBuilder(args);
builder.Services.AddScoped<IProductService, ProductService>();
var app = builder.Build();
// 路由组(共享前缀和过滤器)
var productsGroup = app.MapGroup("/api/v1/products")
.WithTags("Products") // Swagger 分组
.RequireAuthorization() // 组内所有端点需要认证
.WithOpenApi();
// CRUD 端点
productsGroup.MapGet("/", async (IProductService svc, [FromQuery] int page = 1) =>
Results.Ok(await svc.GetAllAsync(page)));
productsGroup.MapGet("/{id:int}", async (int id, IProductService svc) =>
await svc.GetByIdAsync(id) is { } product
? Results.Ok(product)
: Results.NotFound());
productsGroup.MapPost("/", async (
[FromBody] CreateProductDto dto,
IProductService svc,
LinkGenerator links,
HttpContext ctx) =>
{
var created = await svc.CreateAsync(dto);
var url = links.GetUriByName(ctx, "GetProductById", new { id = created.Id });
return Results.Created(url, created);
})
.WithName("CreateProduct")
.Produces<ProductDto>(201)
.ProducesValidationProblem();
productsGroup.MapPut("/{id:int}", async (int id, [FromBody] UpdateProductDto dto, IProductService svc) =>
{
await svc.UpdateAsync(id, dto);
return Results.NoContent();
});
productsGroup.MapDelete("/{id:int}", async (int id, IProductService svc) =>
{
await svc.DeleteAsync(id);
return Results.NoContent();
})
.WithName("GetProductById");
// 端点过滤器(类似 Action Filter)
productsGroup.MapPost("/upload", async (IFormFile file) =>
Results.Ok(new { FileName = file.FileName }))
.AddEndpointFilter(async (context, next) =>
{
var file = context.GetArgument<IFormFile>(0);
if (file.Length > 10 * 1024 * 1024)
return Results.BadRequest("文件不能超过 10MB");
return await next(context);
});
|
Minimal API 与 Controller 对比:
| 特性 |
Controller API |
Minimal API |
| 代码量 |
多,结构化 |
少,扁平化 |
| 性能 |
略低 |
略高(更少反射) |
| 模型绑定 |
自动 |
手动指定 |
| 过滤器 |
完整 Filter Pipeline |
轻量 EndpointFilter |
| 适用场景 |
大型项目、复杂业务 |
微服务、轻量端点 |
外部调用:HttpClientFactory 最佳实践与资源池化
直接 new HttpClient() 存在套接字耗尽和 DNS 缓存问题,IHttpClientFactory 是标准解法:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
| // 方式一:命名客户端
builder.Services.AddHttpClient("github", client =>
{
client.BaseAddress = new Uri("https://api.github.com/");
client.DefaultRequestHeaders.Add("Accept", "application/vnd.github.v3+json");
client.DefaultRequestHeaders.Add("User-Agent", "MyApp/1.0");
client.Timeout = TimeSpan.FromSeconds(30);
})
.AddTransientHttpErrorPolicy(policy => // 集成 Polly:自动重试
policy.WaitAndRetryAsync(3, retry => TimeSpan.FromSeconds(Math.Pow(2, retry))))
.AddTransientHttpErrorPolicy(policy => // 熔断
policy.CircuitBreakerAsync(5, TimeSpan.FromSeconds(30)));
// 使用命名客户端
public class GitHubService
{
private readonly HttpClient _client;
public GitHubService(IHttpClientFactory factory)
=> _client = factory.CreateClient("github");
public async Task<GitHubUser?> GetUserAsync(string login)
{
var response = await _client.GetAsync($"users/{login}");
response.EnsureSuccessStatusCode();
return await response.Content.ReadFromJsonAsync<GitHubUser>();
}
}
// 方式二:类型化客户端(推荐,更易测试)
public class WeatherApiClient
{
private readonly HttpClient _client;
public WeatherApiClient(HttpClient client)
{
_client = client;
_client.BaseAddress = new Uri("https://api.weather.com/");
}
public async Task<WeatherData?> GetCurrentAsync(string city)
=> await _client.GetFromJsonAsync<WeatherData>($"current?city={city}");
}
builder.Services.AddHttpClient<WeatherApiClient>()
.SetHandlerLifetime(TimeSpan.FromMinutes(5)); // 重用时间
// 直接注入类型化客户端
public class ForecastController : ControllerBase
{
private readonly WeatherApiClient _weather;
public ForecastController(WeatherApiClient weather)
=> _weather = weather;
}
|
自动化质量保障:集成测试 (xUnit & TestServer)
集成测试使用 WebApplicationFactory<TProgram> 在内存中启动完整应用:
1
2
3
| dotnet add package Microsoft.AspNetCore.Mvc.Testing
dotnet add package xunit
dotnet add package FluentAssertions
|
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
| // 测试固件:定制测试环境
public class ApiTestFixture : WebApplicationFactory<Program>, IAsyncLifetime
{
private readonly MsSqlContainer _dbContainer = new MsSqlBuilder().Build();
protected override void ConfigureWebHost(IWebHostBuilder builder)
{
builder.ConfigureTestServices(services =>
{
// 替换真实数据库为测试数据库
services.RemoveAll<DbContextOptions<AppDbContext>>();
services.AddDbContext<AppDbContext>(opts =>
opts.UseSqlServer(_dbContainer.GetConnectionString()));
// 替换外部服务
services.RemoveAll<IEmailSender>();
services.AddSingleton<IEmailSender, FakeEmailSender>();
});
}
public async Task InitializeAsync()
{
await _dbContainer.StartAsync();
// 执行数据库迁移
using var scope = Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
await db.Database.MigrateAsync();
}
public new async Task DisposeAsync()
=> await _dbContainer.DisposeAsync();
}
// 测试类
public class ProductsApiTests : IClassFixture<ApiTestFixture>
{
private readonly HttpClient _client;
public ProductsApiTests(ApiTestFixture fixture)
{
_client = fixture.CreateClient();
// 设置 JWT 认证头
_client.DefaultRequestHeaders.Authorization =
new AuthenticationHeaderValue("Bearer", fixture.GenerateTestToken());
}
[Fact]
public async Task GetById_ExistingProduct_Returns200WithProduct()
{
// Arrange - 准备测试数据(略)
// Act
var response = await _client.GetAsync("/api/v1/products/1");
// Assert
response.StatusCode.Should().Be(HttpStatusCode.OK);
var product = await response.Content.ReadFromJsonAsync<ProductDto>();
product.Should().NotBeNull();
product!.Id.Should().Be(1);
product.Name.Should().NotBeNullOrEmpty();
}
[Fact]
public async Task CreateProduct_ValidDto_Returns201WithLocation()
{
// Arrange
var dto = new CreateProductDto
{
Name = "测试产品",
Price = 99.99m,
CategoryId = 1
};
// Act
var response = await _client.PostAsJsonAsync("/api/v1/products", dto);
// Assert
response.StatusCode.Should().Be(HttpStatusCode.Created);
response.Headers.Location.Should().NotBeNull();
var created = await response.Content.ReadFromJsonAsync<ProductDto>();
created!.Name.Should().Be(dto.Name);
}
[Fact]
public async Task CreateProduct_InvalidDto_Returns400WithValidationErrors()
{
// Arrange
var dto = new CreateProductDto { Name = "", Price = -1 }; // 无效数据
// Act
var response = await _client.PostAsJsonAsync("/api/v1/products", dto);
// Assert
response.StatusCode.Should().Be(HttpStatusCode.BadRequest);
var problem = await response.Content.ReadFromJsonAsync<ValidationProblemDetails>();
problem!.Errors.Should().ContainKey("Name");
problem.Errors.Should().ContainKey("Price");
}
[Fact]
public async Task GetById_Unauthorized_Returns401()
{
// 移除认证头
_client.DefaultRequestHeaders.Authorization = null;
var response = await _client.GetAsync("/api/v1/products/1");
response.StatusCode.Should().Be(HttpStatusCode.Unauthorized);
}
}
|
生产发布:Docker 容器化与 Nginx 反向代理配置
多阶段 Dockerfile
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
| # 阶段一:构建
FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build
WORKDIR /src
# 先复制 csproj,利用 Docker 层缓存避免每次重新还原包
COPY ["MyApi/MyApi.csproj", "MyApi/"]
RUN dotnet restore "MyApi/MyApi.csproj"
COPY . .
WORKDIR "/src/MyApi"
RUN dotnet publish "MyApi.csproj" -c Release -o /app/publish \
--no-restore \
/p:UseAppHost=false
# 阶段二:运行(最小化镜像)
FROM mcr.microsoft.com/dotnet/aspnet:8.0 AS final
WORKDIR /app
# 安全:非 root 用户运行
RUN adduser --disabled-password --gecos '' appuser && chown -R appuser /app
USER appuser
COPY --from=build /app/publish .
ENV ASPNETCORE_URLS=http://+:8080
ENV ASPNETCORE_ENVIRONMENT=Production
EXPOSE 8080
ENTRYPOINT ["dotnet", "MyApi.dll"]
|
docker-compose.yml(本地开发)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
| version: '3.8'
services:
api:
build: .
ports:
- "8080:8080"
environment:
- ConnectionStrings__Default=Server=db;Database=MyDb;User=sa;Password=YourStrong@Passw0rd;
- ConnectionStrings__Redis=redis:6379
- ASPNETCORE_ENVIRONMENT=Development
depends_on:
db:
condition: service_healthy
redis:
condition: service_started
restart: unless-stopped
db:
image: mcr.microsoft.com/mssql/server:2022-latest
environment:
- ACCEPT_EULA=Y
- SA_PASSWORD=YourStrong@Passw0rd
ports:
- "1433:1433"
healthcheck:
test: /opt/mssql-tools/bin/sqlcmd -S localhost -U sa -P "$$SA_PASSWORD" -Q "SELECT 1"
interval: 10s
timeout: 5s
retries: 5
redis:
image: redis:7-alpine
ports:
- "6379:6379"
nginx:
image: nginx:alpine
ports:
- "80:80"
- "443:443"
volumes:
- ./nginx.conf:/etc/nginx/nginx.conf:ro
- ./certs:/etc/nginx/certs:ro
depends_on:
- api
|
Nginx 反向代理配置
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
| upstream aspnet_api {
server api:8080;
keepalive 32;
}
server {
listen 80;
server_name api.example.com;
return 301 https://$host$request_uri;
}
server {
listen 443 ssl http2;
server_name api.example.com;
ssl_certificate /etc/nginx/certs/fullchain.pem;
ssl_certificate_key /etc/nginx/certs/privkey.pem;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers HIGH:!aNULL:!MD5;
# 安全响应头
add_header X-Frame-Options DENY;
add_header X-Content-Type-Options nosniff;
add_header X-XSS-Protection "1; mode=block";
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains";
location / {
proxy_pass http://aspnet_api;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection keep-alive;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_cache_bypass $http_upgrade;
proxy_read_timeout 60s;
proxy_send_timeout 60s;
client_max_body_size 50m;
}
}
|
处理转发头(Program.cs): 在 Nginx 后面部署时,需要让 ASP.NET Core 正确识别客户端真实 IP 和协议:
1
2
3
4
5
| // 必须在所有其他中间件之前
app.UseForwardedHeaders(new ForwardedHeadersOptions
{
ForwardedHeaders = ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto
});
|
章节小结:ASP.NET Web API 是一个层次分明、高度可扩展的框架。掌握请求管道的流向是一切的基础——从 URL 进入路由匹配,经过中间件层层处理,到 Filter Pipeline 的精细控制,再到 Controller 的业务执行,最终通过格式化层返回规范化的 HTTP 响应。在此基础上,叠加 JWT 安全、版本管理、限流、可观测性等生产级特性,配合 Docker 容器化部署,即可构建出健壮、可维护的企业级 REST API。