Sisk

请求

本页由英文自动翻译。 阅读原文

请求是表示 HTTP 请求消息的结构体。HttpRequest 对象包含在整个应用程序中处理 HTTP 消息的实用函数。

HTTP 请求由方法、路径、版本、头部和正文组成。

在本文档中,我们将教您如何获取这些元素。

获取请求方法 #

要获取收到的请求的方法,可以使用 Method 属性:

C#
static HttpResponse Index(HttpRequest request)
{
    HttpMethod requestMethod = request.Method;
    ...
}

此属性返回由 HttpMethod 对象表示的请求方法。

注意

与路由方法不同,此属性不提供 RouteMethod.Any 项。相反,它返回真实的请求方法。

获取请求 URL 组件 #

您可以通过请求的某些属性从 URL 中获取各种组件。以下示例使用的 URL 为:

http://localhost:5000/user/[email protected]
组件名称描述组件值
Path获取请求路径。/user/login
FullPath获取请求路径和查询字符串。/user/[email protected]
FullUrl获取完整的 URL 请求字符串。http://localhost:5000/user/[email protected]
Host获取请求主机。localhost
Authority获取请求主机和端口。localhost:5000
QueryString获取请求查询。[email protected]
Query以命名值集合的形式获取请求查询。{StringValueCollection object}
IsSecure确定请求是否使用 SSL(true)或未使用(false)。false

您也可以使用 HttpRequest.Uri 属性,它将上述所有信息包含在一个对象中。

请求元数据和取消 #

Sisk 还会为每个请求附加运行时元数据。这些属性对日志、追踪、本地化、诊断以及长时间运行的操作非常有用:

属性或方法用途
RequestId请求的唯一标识符。启用 IncludeRequestIdHeader 可将其作为 X-Request-Id 返回。
RequestedAtSisk 创建请求对象的时间点。
RemoteAddress从连接解析得到的客户端地址,或来自您的 ForwardingResolver。
Culture从 Accept-Language 解析得到的最佳语言区域,若未匹配则回退到当前语言区域。
DisconnectToken当客户端断开连接时(若配置的 HTTP 引擎支持)发出信号的取消令牌。
Bag在请求处理程序和路由操作之间共享的类型化键/值存储。
GetRawHttpRequest用于诊断的请求文本表示。

获取请求正文 #

某些请求包含正文,例如表单、文件或 API 事务。您可以通过以下属性获取请求的正文:

C#
// 将请求正文获取为字符串,使用请求的编码作为解码器
string body = request.Body;

// 或以字节数组获取
byte[] bodyBytes = request.RawBody;

// 或者,你可以以流的方式获取。
Stream requestStream = request.GetRequestStream();

// 或异步读取正文
Memory<byte> bodyMemory = await request.GetBodyContentsAsync();

也可以使用属性 HasContents 判断请求是否包含正文,以及使用 IsContentAvailable 判断 HTTP 服务器是否已完整接收来自远端的内容。

不能多次通过 GetRequestStream 读取请求内容。若使用此方法读取,RawBody 和 Body 的值也将不可用。无需在请求上下文中手动释放请求流,因为它会在创建它的 HTTP 会话结束时被释放。同时,你可以使用 HttpRequest.RequestEncoding 属性获取最佳编码,以手动解码请求。

服务器对读取请求内容有大小限制,适用于 HttpRequest.Body 和 HttpRequest.RawBody。这些属性会将整个输入流复制到与 HttpRequest.ContentLength 相同大小的本地缓冲区。

请求内容受 HttpServerConfiguration.MaximumContentLength 限制,自 Sisk 1.7 起默认 100 MB(早期版本为 0,即无限制)。限制的适用方式如下:

  • 如果请求声明的 Content-Length 超过限制,服务器将在路由调用前返回 413 Content Too Large 响应。
  • 对于未声明长度的分块或流式正文,在读取时会进行检查。通过 [RawBody]、[Body] 或 [GetRequestStream] 返回的流读取超出限制时会抛出 InsufficientMemoryException,导致返回 500 Internal Server Error,除非你的代码或 Router.CallbackErrorHandler 处理该异常。
  • 同一限制也是接收的 WebSocket 消息的最大大小。

将属性设为 0 可移除限制:

C#
using var app = HttpServer.CreateBuilder()
    .UseConfiguration(config => config.MaximumContentLength = 0)
    .Build();

即使未配置限制,Body 和 RawBody 也无法缓冲超过 Array.MaxLength(约 2 GB)的内容,访问时会抛出异常。仍可通过流式处理更大的内容。

注意

虽然 Sisk 允许这样做,但始终建议遵循 HTTP 语义来构建应用程序,避免在不允许的请求方法中获取或提供内容。阅读 RFC 9110 “HTTP Semantics”。

读取 JSON 请求 #

对于 JSON API,建议使用内置的 JSON 辅助方法,而不是读取 Body 并手动反序列化。它们使用 System.Text.Json ,默认使用 HttpRequest.DefaultJsonSerializerOptions。

C#
public record CreateUserRequest(string Name, string Email);

router.MapPost("/users", (HttpRequest request) =>
{
    CreateUserRequest? body = request.GetJsonContent<CreateUserRequest>();
    if (body is null)
        return new HttpResponse(System.Net.HttpStatusCode.BadRequest);

    return new HttpResponse(System.Net.HttpStatusCode.Created);
});

在已经是异步路由或希望请求取消能够停止反序列化时,请使用异步重载:

C#
router.MapPost("/users", async (HttpRequest request) =>
{
    CreateUserRequest? body =
        await request.GetJsonContentAsync<CreateUserRequest>(request.DisconnectToken);

    if (body is null)
        return new HttpResponse(System.Net.HttpStatusCode.BadRequest);

    return new HttpResponse(System.Net.HttpStatusCode.Created);
});

可以为特定端点传入自定义的 JsonSerializerOptions:

C#
var options = new JsonSerializerOptions(JsonSerializerDefaults.Web)
{
    PropertyNameCaseInsensitive = true
};

UserDto? user = request.GetJsonContent<UserDto>(options);

对于 Native AOT 或对裁剪敏感的应用程序,使用由 JsonSerializerContext 生成的 JsonTypeInfo<T> 重载:

C#
[JsonSerializable(typeof(CreateUserRequest))]
public partial class AppJsonSerializerContext : JsonSerializerContext
{
}

CreateUserRequest? body =
    await request.GetJsonContentAsync(
        AppJsonSerializerContext.Default.CreateUserRequest,
        request.DisconnectToken);

相同的“一次读取”规则适用于 JSON 辅助方法:在 Sisk 通过 GetJsonContent、GetJsonContentAsync、Body 或 RawBody 读取请求流后,之后不能再通过 GetRequestStream() 读取相同的正文。

获取请求上下文 #

HTTP Context 是 Sisk 专有的对象,用于存储 HTTP 服务器、路由、路由器和请求处理程序的信息。你可以使用它在这些对象难以组织的环境中进行管理。

可以使用静态方法 HttpContext.GetCurrentContext() 获取当前正在执行的 HttpContext。该方法返回当前线程中正在处理的请求的上下文。

C#
HttpContext context = HttpContext.GetCurrentContext();

日志模式 #

HttpContext.LogMode 属性允许你控制当前请求的日志行为。可以为特定请求启用或禁用日志,覆盖默认的服务器配置。

C#
// Disable logging for this request
context.LogMode = LogOutputMode.None;

请求包 #

RequestBag 对象包含在请求处理程序之间传递的存储信息,可在最终目标处使用。该对象也可被路由回调之后运行的请求处理程序使用。

提示

此属性也可以通过 HttpRequest.Bag 属性访问。

Middleware/AuthenticateUserRequestHandler.csC#
public class AuthenticateUserRequestHandler : IRequestHandler
{
    public string Identifier { get; init; } = Guid.NewGuid().ToString();
    public RequestHandlerExecutionMode ExecutionMode { get; init; } = RequestHandlerExecutionMode.BeforeResponse;
    
    public HttpResponse? Execute(HttpRequest request, HttpContext context)
    {
        if (request.Headers.Authorization != null)
        {
            context.RequestBag.Add("AuthenticatedUser", new User("Bob"));
            return null;
        }
        else
        {
            return new HttpResponse(System.Net.HttpStatusCode.Unauthorized);
        }
    }
}

上述请求处理程序会在请求包中定义 AuthenticatedUser,随后可在最终回调中使用:

Controller/MyController.csC#
public class MyController
{
    [RouteGet("/")]
    [RequestHandler<AuthenticateUserRequestHandler>]
    static HttpResponse Index(HttpRequest request)
    {
        User authUser = request.Context.RequestBag["AuthenticatedUser"];
        
        return new HttpResponse() {
            Content = new StringContent($"Hello, {authUser.Name}!")
        };
    }
}

你也可以使用 Bag.Set() 和 Bag.Get() 辅助方法按类型单例获取或设置对象。

TypedValueDictionary 类还提供 GetValue 和 SetValue 方法,以获得更细粒度的控制。

Middleware/Authenticate.csC#
public class Authenticate : RequestHandler
{
    public override HttpResponse? Execute(HttpRequest request, HttpContext context)
    {
        request.Bag.Set<User>(authUser);
    }
}
Controller/MyController.csC#
[RouteGet("/")]
[RequestHandler<Authenticate>]
public static HttpResponse GetUser(HttpRequest request)
{
    var user = request.Bag.Get<User>();
    ...
}

原子上下文值 #

请求包不是线程安全的。当包中的数值可能在同一请求的并发代码(例如路由启动的并行任务)中被修改时,请使用 HttpContext.Interlocked。它返回一个 HttpContextInterlocked 对象,可对请求包中命名值进行原子操作:

方法描述
Add(name, value)将 value 加到已存储的值上;如果 name 未设置,则存储 value。
Exchange(name, value)替换已存储的值并返回之前的值;如果 name 未设置,则返回 value。
CompareExchange(name, value, comparand, notFound = -1)仅当已存储的值等于 comparand 时才用 value 替换,并返回原始值;如果 name 未设置,则返回 notFound。
Inspect(name)返回已存储的值;如果 name 未设置,则返回 null。

值在请求包中以 double 存储。接受 int 或 long 参数的重载会将其转换为 double。

C#
router.MapPost("/import", async (HttpRequest request) =>
{
    string[] lines = request.Body.Split('\n');

    await Parallel.ForEachAsync(lines, async (line, cancellation) =>
    {
        if (await TryImportAsync(line, cancellation))
            request.Context.Interlocked.Add("imported", 1);
    });

    double imported = request.Context.Interlocked.Inspect("imported") ?? 0;
    return new HttpResponse($"{imported} line(s) imported");
});

注意

这些操作仅在相互之间的 Interlocked 调用中保持原子性。直接通过 RequestBag 读取或写入同一键并未与它们同步。

获取表单数据 #

您可以使用下面的示例在 StringKeyStoreCollection 中获取表单数据值:

Controller/Auth.csC#
[RoutePost("/auth")]
public HttpResponse Index(HttpRequest request)
{
    var form = request.GetFormContent();

    string? username = form["username"];
    string? password = form["password"];

    if (AttempLogin(username, password))
    {
        ...
    }
}

当请求正文可能较大或需要取消支持时,异步版本非常有用:

C#
var form = await request.GetFormContentAsync(request.DisconnectToken);

获取 multipart 表单数据 #

Sisk 的 HTTP 请求允许获取上传的 multipart 内容,例如文件、表单字段或任何二进制内容。

Controller/Auth.csC#
[RoutePost("/upload-contents")]
public HttpResponse Index(HttpRequest request)
{
    // 以下方法将整个请求输入读取为一个
    // MultipartObject 数组
    var multipartFormDataObjects = request.GetMultipartFormContent();
    
    foreach (MultipartObject uploadedObject in multipartFormDataObjects)
    {
        // Multipart 表单数据提供的文件名。
        // 如果对象不是文件,则返回 null。
        Console.WriteLine("File name       : " + uploadedObject.Filename);

        // multipart 表单数据对象的字段名。
        Console.WriteLine("Field name      : " + uploadedObject.Name);

        // multipart 表单数据的内容长度。
        Console.WriteLine("Content length  : " + uploadedObject.ContentLength);

        // 根据文件头确定每个已知内容类型的图像格式
        // 如果内容不是已识别的常见文件
        // 格式,此方法将返回 MultipartObjectCommonFormat.Unknown
        Console.WriteLine("Common format   : " + uploadedObject.GetCommonFileFormat());
    }
}

在路由为异步时使用 GetMultipartFormContentAsync:

C#
var multipartFormDataObjects =
    await request.GetMultipartFormContentAsync(request.DisconnectToken);

您可以进一步了解 Sisk 的 Multipart 表单对象 及其方法、属性和功能。

检测客户端断开连接 #

自 Sisk v1.15 起,框架通过 HttpRequest.DisconnectToken 提供取消令牌。当配置的 HTTP 引擎支持断开检测时,如果客户端在响应完成前关闭连接,该令牌会被取消。这对于在客户端不再等待结果时停止长时间运行的操作非常有用。

C#
router.MapGet("/connect", async (HttpRequest req) =>
{
    // 从请求获取断开连接令牌
    var dc = req.DisconnectToken;

    await LongOperationAsync(dc);

    return new HttpResponse();
});

此令牌并非所有 HTTP 引擎都兼容,每个引擎都需要相应实现。

默认的 Sisk 引擎基于 System.Net.HttpListener,不支持客户端断开检测。当使用默认引擎时,DisconnectToken 为 CancellationToken.None;实际上它是一个不产生取消的令牌,应视为不可用。

Cadente 引擎 支持 DisconnectToken。如果您的路由依赖于断开感知的取消,请使用 Cadente 或其他明确实现此行为的引擎。即使使用了支持的引擎,取消也是协作式的:将令牌传递给异步 API 并在自己的长时间工作中检查它。

Server-sent events 支持 #

Sisk 支持 Server-sent events,它允许以流的方式发送块并保持服务器与客户端之间的连接存活。

调用 HttpRequest.GetEventSource 方法会将 HttpRequest 置于监听状态。此时,该 HTTP 请求的上下文不再期待 HttpResponse,因为它会与服务器端事件发送的包重叠。

在发送完所有包后,回调必须返回 Close 方法,它会向服务器发送最终响应并指示流已结束。

无法预测将要发送的所有包的总长度,因此无法使用 Content-Length 头部来确定连接的结束。

大多数浏览器默认情况下,服务器端事件不支持发送除 GET 方法之外的 HTTP 头或方法。因此,在使用需要特定请求头的 event-source 请求处理程序时需谨慎,因为它们可能不会包含这些头。

此外,大多数浏览器在客户端收到所有包后若未调用 EventSource.close 方法会重新启动流,导致服务器端无限额外处理。为避免此类问题,通常会发送一个最终包,指示事件源已完成所有包的发送。

下面的示例展示了浏览器如何与支持服务器端事件的服务器通信。

sse-example.htmlHTML
<html>
    <body>
        <b>Fruits:</b>
        <ul></ul>
    </body>
    <script>
        const evtSource = new EventSource('http://localhost:5555/event-source');
        const eventList = document.querySelector('ul');
        
        evtSource.onmessage = (e) => {
            const newElement = document.createElement("li");

            newElement.textContent = `message: ${e.data}`;
            eventList.appendChild(newElement);

            if (e.data == "Tomato") {
                evtSource.close();
            }
        }
    </script>
</html>

并逐步向客户端发送消息:

Controller/MyController.csC#
public class MyController
{
    [RouteGet("/event-source")]
    public async Task<HttpResponse> ServerEventsResponse(HttpRequest request)
    {
        var serverEvents = await request.GetEventSourceAsync ();
        
        string[] fruits = new[] { "Apple", "Banana", "Watermelon", "Tomato" };
        
        foreach (string fruit in fruits)
        {
            await serverEvents.SendAsync(fruit);
            await Task.Delay(1500);
        }

        return await serverEvents.CloseAsync();
    }
}

运行此代码时,我们期望得到类似以下的结果:

解析代理的 IP 和主机 #

Sisk 可以与代理一起使用,因此在客户端到代理的事务中,IP 地址可能会被代理端点替代。

您可以使用 forwarding resolvers 在 Sisk 中定义自己的解析器。

头部编码 #

头部编码在某些实现中可能会出现问题。在 Windows 上不支持 UTF-8 头部,因此使用 ASCII。Sisk 内置了编码转换器,可用于解码错误编码的头部。

此操作成本较高,默认已禁用,但可以通过 HttpServerConfiguration.NormalizeHeadersEncodings 启用。

输入关键词以搜索文档和 API 参考。