在 ASP.NET Core 中使用 HttpContext

HttpContext 封装了有关个别 HTTP 请求和响应的所有信息。 收到 HTTP 请求时,HttpContext 实例会进行初始化。 HttpContext 实例可通过中间件和应用框架(如 Web API 控制器、Razor Pages、SignalR、gRPC 等)访问。

有关访问 HttpContext 的详细信息,请参阅在 ASP.NET Core 中访问 HttpContext

HttpRequest

HttpContext.Request 提供对 HttpRequest 的访问。 HttpRequest 包含有关传入 HTTP 请求的信息,并在服务器收到 HTTP 请求时被初始化。 HttpRequest 不是只读的,中间件可以在中间件管道中更改请求值。

HttpRequest 上常用的成员包括:

properties 说明 示例
HttpRequest.Path 请求路径。 /en/article/getstarted
HttpRequest.Method 请求方法。 GET
HttpRequest.Headers 请求标头的集合。 user-agent=Edge
x-custom-header=MyValue
HttpRequest.RouteValues 路由值的集合。 当请求与路由匹配时,会设置该集合。 language=en
article=getstarted
HttpRequest.Query QueryString 中分析的查询值的集合。 filter=hello
page=1
HttpRequest.ReadFormAsync() 将请求正文读取为窗体并返回窗体值集合的方法。 有关为何 ReadFormAsync 应该用于访问窗体数据的信息,请参阅首选 ReadFormAsync 而不是 Request.Form email=user@contoso.com
password=TNkt4taM
HttpRequest.Body 用于读取请求正文的 Stream UTF-8 JSON 有效负载

获取请求头

HttpRequest.Headers 提供对使用 HTTP 请求发送的请求头的访问权限。 有两种方法可以使用此集合访问标头:

  • 向标头集合上的索引器提供标头名称。 标头名称不区分大小写。 索引器可以访问任何标头值。
  • 标头集合还具有用于获取和设置常用 HTTP 标头的属性。 这些属性提供了一种快速的、IntelliSense 驱动的方式来访问标头。
var builder = WebApplication.CreateBuilder(args);
var app = builder.Build();

app.MapGet("/", (HttpRequest request) =>
{
    var userAgent = request.Headers.UserAgent;
    var customHeader = request.Headers["x-custom-header"];

    return Results.Ok(new { userAgent = userAgent, customHeader = customHeader });
});

app.Run();

有关高效处理多次出现的标头的信息,请参阅 StringValues 简介

读取请求正文

HTTP 请求可以包含请求正文。 请求正文是与请求关联的数据,例如 HTML 窗体的内容、UTF-8 JSON 有效负载或文件。

HttpRequest.Body 允许使用 Stream 读取请求正文:

var builder = WebApplication.CreateBuilder(args);
var app = builder.Build();

app.MapPost("/uploadstream", async (IConfiguration config, HttpContext context) =>
{
    var filePath = Path.Combine(config["StoredFilesPath"], Path.GetRandomFileName());

    await using var writeStream = File.Create(filePath);
    await context.Request.Body.CopyToAsync(writeStream);
});

app.Run();

HttpRequest.Body 可以直接读取,也可以与接受流的其他 API 一起使用。

注意

最小 API 支持将 HttpRequest.Body 直接绑定到 Stream 参数。

启用请求正文缓冲

请求正文只能从头到尾读取一次。 仅转发读取请求正文可避免缓冲整个请求正文的开销,并减少内存使用量。 但是,在某些情况下,需要多次读取请求正文。 例如,中间件可能需要读取请求正文,然后使其后退,以便它可用于终结点。

EnableBuffering 扩展方法允许缓冲 HTTP 请求正文,并且是启用多次读取的推荐方法。 由于请求可以是任何大小,因此 EnableBuffering 支持将大型请求正文缓冲到磁盘,或者完全拒绝它们的选项。

以下示例中的中间件:

  • 使用 EnableBuffering 启用多次读取。 必须在读取请求正文之前调用它。
  • 读取请求正文。
  • 将请求正文后退到开始位置,以便其他中间件或终结点可以读取它。
var builder = WebApplication.CreateBuilder(args);
var app = builder.Build();

app.Use(async (context, next) =>
{
    context.Request.EnableBuffering();
    await ReadRequestBody(context.Request.Body);
    context.Request.Body.Position = 0;
    
    await next.Invoke();
});

app.Run();

BodyReader

读取请求正文的另一种方法是使用 HttpRequest.BodyReader 属性。 BodyReader 属性将请求正文公开为 PipeReader。 此 API 来自 I/O 管道,它是读取请求正文的一种高级的、高性能的方法。

读取器直接访问请求正文,并代表调用方管理内存。 与 HttpRequest.Body 不同的是,读取器不会将请求数据复制到缓冲区中。 但是,读取器的使用比流更复杂,应谨慎使用。

有关如何从 BodyReader 中读取内容的信息,请参阅 I/O 管道 PipeReader

HttpResponse

HttpContext.Response 提供对 HttpResponse 的访问。 HttpResponse 用于设置有关发回到客户端的 HTTP 响应的信息。

HttpResponse 上常用的成员包括:

properties 说明 示例
HttpResponse.StatusCode 响应代码。 必须在写入响应正文之前进行设置。 200
HttpResponse.ContentType 响应 content-type 标头。 必须在写入响应正文之前进行设置。 application/json
HttpResponse.Headers 响应头的集合。 必须在写入响应正文之前进行设置。 server=Kestrel
x-custom-header=MyValue
HttpResponse.Body 用于编写响应正文的 Stream 生成的网页

设置响应头

HttpResponse.Headers 提供对随 HTTP 响应发送的响应头的访问权限。 有两种方法可以使用此集合访问标头:

  • 向标头集合上的索引器提供标头名称。 标头名称不区分大小写。 索引器可以访问任何标头值。
  • 标头集合还具有用于获取和设置常用 HTTP 标头的属性。 这些属性提供了一种快速的、IntelliSense 驱动的方式来访问标头。
var builder = WebApplication.CreateBuilder(args);
var app = builder.Build();

app.MapGet("/", (HttpResponse response) =>
{
    response.Headers.CacheControl = "no-cache";
    response.Headers["x-custom-header"] = "Custom value";

    return Results.File(File.OpenRead("helloworld.txt"));
});

app.Run();

在响应启动后,应用无法修改标头。 响应启动后,标头被发送到客户端。 通过刷新响应正文或调用 HttpResponse.StartAsync(CancellationToken) 来启动响应。 HttpResponse.HasStarted 属性指示响应是否已启动。 在响应启动后,尝试修改标头会引发错误:

System.InvalidOperationException:标头是只读的,响应已启动。

注意

除非启用响应缓冲,否则所有写入操作(例如 WriteAsync)都会在内部刷新响应正文并将响应标记为已启动。 默认禁用响应缓冲。

写入响应正文

HTTP 响应可以包含响应正文。 响应正文是与响应关联的数据,例如生成的网页内容、UTF-8 JSON 有效负载或文件。

HttpResponse.Body 允许使用 Stream 写入响应正文:

var builder = WebApplication.CreateBuilder(args);
var app = builder.Build();

app.MapPost("/downloadfile", async (IConfiguration config, HttpContext context) =>
{
    var filePath = Path.Combine(config["StoredFilesPath"], "helloworld.txt");

    await using var fileStream = File.OpenRead(filePath);
    await fileStream.CopyToAsync(context.Response.Body);
});

app.Run();

HttpResponse.Body 可以直接写入,也可以与写入流的其他 API 一起使用。

BodyWriter

写入响应正文的另一种方法是使用 HttpResponse.BodyWriter 属性。 BodyWriter 属性将响应正文公开为 PipeWriter。 此 API 来自 I/O 管道,它是写入响应的一种高级的、高性能的方法。

编写器提供对响应正文的直接访问,并代表调用方管理内存。 与 HttpResponse.Body 不同的是,编写器不会将请求数据复制到缓冲区中。 然而,编写器的使用比流更复杂,编写器代码应经过彻底测试。

有关如何将内容写入 BodyWriter 的信息,请参阅 I/O 管道 PipeWriter

设置响应尾部

HTTP/2 和 HTTP/3 支持响应尾部。 尾部是在响应正文完成后随响应一起发送的标头。 由于尾部是在响应正文之后发送的,因此可以随时将尾部添加到响应中。

以下代码使用 AppendTrailer 设置尾部:

var builder = WebApplication.CreateBuilder(args);
var app = builder.Build();

app.MapGet("/", (HttpResponse response) =>
{
    // Write body
    response.WriteAsync("Hello world");

    if (response.SupportsTrailers())
    {
        response.AppendTrailer("trailername", "TrailerValue");
    }
});

app.Run();

RequestAborted

HttpContext.RequestAborted 取消令牌可用于通知 HTTP 请求已被客户端或服务器中止。 应将该取消令牌传递给长时间运行的任务,这样就能在请求中止时取消它们。 例如,中止数据库查询或 HTTP 请求以获取在响应中返回的数据。

var builder = WebApplication.CreateBuilder(args);
var app = builder.Build();

var httpClient = new HttpClient();
app.MapPost("/books/{bookId}", async (int bookId, HttpContext context) =>
{
    var stream = await httpClient.GetStreamAsync(
        $"http://contoso/books/{bookId}.json", context.RequestAborted);

    // Proxy the response as JSON
    return Results.Stream(stream, "application/json");
});

app.Run();

RequestAborted 取消令牌不需要用于请求正文读取操作,因为读取始终会在请求中止时立即引发。 写入响应正文时通常也不需要 RequestAborted 令牌,因为当请求中止时,写入会立即不起作用。

在某些情况下,将 RequestAborted 令牌传递给写入操作可能是使用 OperationCanceledException 强制写入循环提前退出的便捷方法。 但是,通常最好将 RequestAborted 令牌传递到负责检索响应正文内容的任何异步操作中。

注意

最小 API 支持将 HttpContext.RequestAborted 直接绑定到 CancellationToken 参数。

Abort()

HttpContext.Abort() 方法可用于中止来自服务器的 HTTP 请求。 中止 HTTP 请求会立即触发 HttpContext.RequestAborted 取消令牌,并向客户端发送通知,指明服务器中止了请求。

以下示例中的中间件:

  • 添加针对恶意请求的自定义检查。
  • 如果请求是恶意请求,则中止 HTTP 请求。
var builder = WebApplication.CreateBuilder(args);
var app = builder.Build();

app.Use(async (context, next) =>
{
    if (RequestAppearsMalicious(context.Request))
    {
        // Malicious requests don't even deserve an error response (e.g. 400).
        context.Abort();
        return;
    }

    await next.Invoke();
});

app.Run();

User

HttpContext.User 属性用于获取或设置请求的用户,由 ClaimsPrincipal 表示。 ClaimsPrincipal 通常由 ASP.NET Core 身份验证设置。

var builder = WebApplication.CreateBuilder(args);
var app = builder.Build();

app.MapGet("/user/current", [Authorize] async (HttpContext context) =>
{
    var user = await GetUserAsync(context.User.Identity.Name);
    return Results.Ok(user);
});

app.Run();

注意

最小 API 支持将 HttpContext.User 直接绑定到 ClaimsPrincipal 参数。

Features

HttpContext.Features 属性提供对当前请求的功能接口集合的访问。 由于功能集合即使在请求的上下文中也是可变的,所以可使用中间件来修改集合并添加对其他功能的支持。 某些高级功能只能通过功能集合访问关联接口提供。

如下示例中:

var builder = WebApplication.CreateBuilder(args);
var app = builder.Build();

app.MapGet("/long-running-stream", async (HttpContext context) =>
{
    var feature = context.Features.Get<IHttpMinRequestBodyDataRateFeature>();
    if (feature != null)
    {
        feature.MinDataRate = null;
    }

    // await and read long-running stream from request body.
    await Task.Yield();
});

app.Run();

有关使用请求功能和 HttpContext 的详细信息,请参阅 ASP.NET Core 中的请求功能

HttpContext 并非线程安全型

本文主要讨论在 Razor Pages、控制器、中间件等的请求和响应流中使用 HttpContext。在请求和响应流外部使用 HttpContext 时,应考虑以下几点:

  • HttpContext 并非线程安全型,从多个线程访问它可能会导致异常、数据损坏和通常不可预知的结果。
  • 应慎重使用 IHttpContextAccessor 接口。 与往常一样,HttpContext 不得在请求流外部捕获。 IHttpContextAccessor
    • 依赖于 AsyncLocal<T>,它可能会对异步调用产生负面影响。
    • 创建对“环境状态”的依赖项,这使得测试更加困难。
  • 如果在请求流外部访问,IHttpContextAccessor.HttpContext 可能为 null
  • 要从请求流外部的 HttpContext 中访问信息,请复制请求流内部的信息。 请小心复制实际数据,而不只是引用。 例如,在离开请求流之前,复制相关标头值或按键复制整个字典,而不是复制对 IHeaderDictionary 的引用。
  • 不要在构造函数中捕获 IHttpContextAccessor.HttpContext

以下示例记录在从 /branch 终结点发出请求时的 GitHub 分支:

using System.Text.Json;
using HttpContextInBackgroundThread;
using Microsoft.Net.Http.Headers;

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddHttpContextAccessor();
builder.Services.AddHostedService<PeriodicBranchesLoggerService>();

builder.Services.AddHttpClient("GitHub", httpClient =>
{
    httpClient.BaseAddress = new Uri("https://api.github.com/");

    // The GitHub API requires two headers. The Use-Agent header is added
    // dynamically through UserAgentHeaderHandler
    httpClient.DefaultRequestHeaders.Add(
        HeaderNames.Accept, "application/vnd.github.v3+json");
}).AddHttpMessageHandler<UserAgentHeaderHandler>();

builder.Services.AddTransient<UserAgentHeaderHandler>();

var app = builder.Build();

app.MapGet("/", () => "Hello World!");

app.MapGet("/branches", async (IHttpClientFactory httpClientFactory,
                         HttpContext context, Logger<Program> logger) =>
{
    var httpClient = httpClientFactory.CreateClient("GitHub");
    var httpResponseMessage = await httpClient.GetAsync(
        "repos/dotnet/AspNetCore.Docs/branches");

    if (!httpResponseMessage.IsSuccessStatusCode) 
        return Results.BadRequest();

    await using var contentStream =
        await httpResponseMessage.Content.ReadAsStreamAsync();

    var response = await JsonSerializer.DeserializeAsync
        <IEnumerable<GitHubBranch>>(contentStream);

    app.Logger.LogInformation($"/branches request: " +
                              $"{JsonSerializer.Serialize(response)}");

    return Results.Ok(response);
});

app.Run();

GitHub API 需要两个标头。 User-Agent 标头由 UserAgentHeaderHandler 动态添加:

using System.Text.Json;
using HttpContextInBackgroundThread;
using Microsoft.Net.Http.Headers;

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddHttpContextAccessor();
builder.Services.AddHostedService<PeriodicBranchesLoggerService>();

builder.Services.AddHttpClient("GitHub", httpClient =>
{
    httpClient.BaseAddress = new Uri("https://api.github.com/");

    // The GitHub API requires two headers. The Use-Agent header is added
    // dynamically through UserAgentHeaderHandler
    httpClient.DefaultRequestHeaders.Add(
        HeaderNames.Accept, "application/vnd.github.v3+json");
}).AddHttpMessageHandler<UserAgentHeaderHandler>();

builder.Services.AddTransient<UserAgentHeaderHandler>();

var app = builder.Build();

app.MapGet("/", () => "Hello World!");

app.MapGet("/branches", async (IHttpClientFactory httpClientFactory,
                         HttpContext context, Logger<Program> logger) =>
{
    var httpClient = httpClientFactory.CreateClient("GitHub");
    var httpResponseMessage = await httpClient.GetAsync(
        "repos/dotnet/AspNetCore.Docs/branches");

    if (!httpResponseMessage.IsSuccessStatusCode) 
        return Results.BadRequest();

    await using var contentStream =
        await httpResponseMessage.Content.ReadAsStreamAsync();

    var response = await JsonSerializer.DeserializeAsync
        <IEnumerable<GitHubBranch>>(contentStream);

    app.Logger.LogInformation($"/branches request: " +
                              $"{JsonSerializer.Serialize(response)}");

    return Results.Ok(response);
});

app.Run();

UserAgentHeaderHandler

using Microsoft.Net.Http.Headers;

namespace HttpContextInBackgroundThread;

public class UserAgentHeaderHandler : DelegatingHandler
{
    private readonly IHttpContextAccessor _httpContextAccessor;
    private readonly ILogger _logger;

    public UserAgentHeaderHandler(IHttpContextAccessor httpContextAccessor,
                                  ILogger<UserAgentHeaderHandler> logger)
    {
        _httpContextAccessor = httpContextAccessor;
        _logger = logger;
    }

    protected override async Task<HttpResponseMessage> 
                                    SendAsync(HttpRequestMessage request, 
                                    CancellationToken cancellationToken)
    {
        var contextRequest = _httpContextAccessor.HttpContext?.Request;
        string? userAgentString = contextRequest?.Headers["user-agent"].ToString();
        
        if (string.IsNullOrEmpty(userAgentString))
        {
            userAgentString = "Unknown";
        }

        request.Headers.Add(HeaderNames.UserAgent, userAgentString);
        _logger.LogInformation($"User-Agent: {userAgentString}");

        return await base.SendAsync(request, cancellationToken);
    }
}

在上述代码中,当 HttpContextnull 时,userAgent 字符串设置为 "Unknown"。 如果可能,HttpContext 应显式传递给服务。 显式传入 HttpContext 数据:

  • 使服务 API 在请求流外部更易于使用。
  • 性能更佳。
  • 与依赖环境状态相比,使代码更易于理解和推理。

当服务必须访问 HttpContext 时,它应考虑到在未从请求线程调用时 HttpContextnull 的可能性。

应用程序还包含 PeriodicBranchesLoggerService,它每 30 秒记录一次指定存储库的打开 GitHub 分支:

using System.Text.Json;

namespace HttpContextInBackgroundThread;

public class PeriodicBranchesLoggerService : BackgroundService
{
    private readonly IHttpClientFactory _httpClientFactory;
    private readonly ILogger _logger;
    private readonly PeriodicTimer _timer;

    public PeriodicBranchesLoggerService(IHttpClientFactory httpClientFactory,
                                         ILogger<PeriodicBranchesLoggerService> logger)
    {
        _httpClientFactory = httpClientFactory;
        _logger = logger;
        _timer = new PeriodicTimer(TimeSpan.FromSeconds(30));
    }

    protected override async Task ExecuteAsync(CancellationToken stoppingToken)
    {
        while (await _timer.WaitForNextTickAsync(stoppingToken))
        {
            try
            {
                // Cancel sending the request to sync branches if it takes too long
                // rather than miss sending the next request scheduled 30 seconds from now.
                // Having a single loop prevents this service from sending an unbounded
                // number of requests simultaneously.
                using var syncTokenSource = CancellationTokenSource.CreateLinkedTokenSource(stoppingToken);
                syncTokenSource.CancelAfter(TimeSpan.FromSeconds(30));
                
                var httpClient = _httpClientFactory.CreateClient("GitHub");
                var httpResponseMessage = await httpClient.GetAsync("repos/dotnet/AspNetCore.Docs/branches",
                                                                    stoppingToken);

                if (httpResponseMessage.IsSuccessStatusCode)
                {
                    await using var contentStream =
                        await httpResponseMessage.Content.ReadAsStreamAsync(stoppingToken);

                    // Sync the response with preferred datastore.
                    var response = await JsonSerializer.DeserializeAsync<
                        IEnumerable<GitHubBranch>>(contentStream, cancellationToken: stoppingToken);

                    _logger.LogInformation(
                        $"Branch sync successful! Response: {JsonSerializer.Serialize(response)}");
                }
                else
                {
                    _logger.LogError(1, $"Branch sync failed! HTTP status code: {httpResponseMessage.StatusCode}");
                }
            }
            catch (Exception ex)
            {
                _logger.LogError(1, ex, "Branch sync failed!");
            }
        }
    }

    public override Task StopAsync(CancellationToken stoppingToken)
    {
        // This will cause any active call to WaitForNextTickAsync() to return false immediately.
        _timer.Dispose();
        // This will cancel the stoppingToken and await ExecuteAsync(stoppingToken).
        return base.StopAsync(stoppingToken);
    }
}

PeriodicBranchesLoggerService 是一项托管服务,它在请求和响应流外部运行。 来自 PeriodicBranchesLoggerService 的日志记录的 HttpContext 为 null。 PeriodicBranchesLoggerService 的写入不依赖于 HttpContext

using System.Text.Json;
using HttpContextInBackgroundThread;
using Microsoft.Net.Http.Headers;

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddHttpContextAccessor();
builder.Services.AddHostedService<PeriodicBranchesLoggerService>();

builder.Services.AddHttpClient("GitHub", httpClient =>
{