这里的超时响应指的是ReadTimeOut
,即发送请求内容完毕到接收响应数据开始的这段时间。普通HTTP请求可能在这段时间没有响应超时。
HTTP分块传输(Chunked Transfer Encoding)中每个数据块的到达都会刷新ReadTimeOut
。服务器推送事件(SSE)中服务器会自动发送心跳消息刷新ReadTimeOut
。由于这种分块或流式传输的方式每次消息处理的业务量和数据量较小,可以减少超时。
这两种只是让请求方尽快看到结果,数据出来一次就推送一次,并不能减少全部数据处理完毕的时间。而js可以收到一次回调我们的代码,打印或者处理一次,而不是收到全部所有数据后再将控制权交给我们的代码。要分批返回数据,就要求服务端的业务逻辑代码不要一次性处理所有数据,而是分批处理或查询。
HTTP/1.1 200 OK
Content-Type: application/json
Transfer-Encoding: chunked
4A
[{"id": 1, "name": "Alice"}, {"id": 2, "name": "Bob"}, {"id": 3, "name": "Charlie"}]
4C
[{"id": 4, "name": "David"}, {"id": 5, "name": "Eve"}, {"id": 6, "name": "Frank"}]
42
[{"id": 7, "name": "Grace"}, {"id": 8, "name": "Helen"}, {"id": 9, "name": "Ian"}]
0
Transfer-Encoding: chunked
头部表明这是一个分块传输响应这一个的问题在于服务器发送和浏览器接收是什么形式?什么表现?我需要试一下。
[HttpGet]
public async IAsyncEnumerable<string> Get()
{
var dataList = new[]
{
new { Id = 1, Name = "Alice" },
new { Id = 2, Name = "Bob" },
new { Id = 3, Name = "Charlie" }
};
foreach (var data in dataList)
{
// 模拟数据处理延迟
await Task.Delay(2000); // 模拟处理时间
yield return $"ID: {data.Id}, Name: {data.Name}\n";
}
}
服务端返回一个异步流。使用了IAsyncEnumerable<T>
,kestrel就会为响应头添加分块字段。具体来说kestrel内部会使用await foreach
迭代这个方法,等待每个数据块的生成,并一次次推送响应数据
async function fetchData() {
try {
const response = await fetch('/data');
if (!response.ok) {
throw new Error('Network response was not ok');
}
const reader = response.body.getReader();
const decoder = new TextDecoder('utf-8');
const list = document.getElementById('data-list');
while (true) {
const { value, done } = await reader.read();
if (done) break;
const textChunk = decoder.decode(value, { stream: true });
const li = document.createElement('li');
li.textContent = textChunk.trim();
list.appendChild(li);
}
} catch (error) {
console.error('Fetch Error:', error);
}
}
看看实际运行效果,每次读取响应体都会有2秒延迟
看这个时间解析,第一次读取时,遇到第一个Task.Delay(2000)
,然后开始响应数据。绿色部分走完,浏览器得到响应第一部分数据,进入蓝色部分。
这种只能解决传输慢的问题,让接收方尽早看到数据,但不能加快全部数据响应完成时间。
流式传输服务端需要设置特定响应头,然后保持http连接,直接向响应中写数据和推送,而不是返回数据,释放连接。
public async Task<IActionResult> Stream()
{
HttpContext.Response.ContentType = "text/event-stream";
HttpContext.Response.Headers.Add("Cache-Control", "no-cache");
HttpContext.Response.Headers.Add("Connection", "keep-alive");
// 周期性推数据
while (true)
{
// 推送模拟数据
var message = $"data: {System.Text.Json.JsonSerializer.Serialize(new { message = "Hello, world!", timestamp = DateTime.UtcNow })}\n\n";
await Response.WriteAsync(message);
await Response.Body.FlushAsync();
//1S间隔再推送
await Task.Delay(1000);
}
}
const eventSource = new EventSource('/api/sse/stream');
eventSource.onmessage = function(event) {
const message = JSON.parse(event.data);
const messageElement = document.createElement('div');
messageElement.textContent = `Message: ${message.message}, Timestamp: ${message.timestamp}`;
document.getElementById('messages').appendChild(messageElement);
};
eventSource.onerror = function(event) {
console.error('Error:', event);
};
不过SSE只能在请求地址中增加参数,没法定义携带的请求头,比如Authorization。
范围请求似乎不是我们手动直接处理,而是浏览器和服务器自动完成的。比如大文件下载断点续传。这种不在乎超时问题,似乎不应该纳入此次讨论范畴。
但是我好奇的是,范围请求的流程。浏览器如何决定下载一个压缩包时发送范围请求还是普通请求?浏览器再最开始如何知道范围大小?这似乎有个探测阶段才行,那么浏览器和服务器是如何互动的?如果有探测,那么服务器怎么知道这是一个探测请求,而不是一个下载请求?
确实有一个探测阶段,使用head
方法,而不是常规的get
post
,仅获取文件大小信息而不下载内容。
HEAD /example.txt HTTP/1.1
Host: example.com
HTTP/1.1 200 OK
Accept-Ranges: bytes
Content-Length: 100
Content-Type: text/plain
但探测阶段并不总是存在。当我们点击一个链接,浏览器并不知道这是一个大文件。所以浏览器通常会发一个get请求,直接下载文件,并从头部了解并记录是否支持范围请求Accept-Ranges
和文件总大小Content-Length
,以便再暂停下载之后,再次点击下载时决定能否换成发送范围请求。
range
分支了。第一个分支供完整下载,第二个分支供范围下载[HttpGet]
public IActionResult GetFile(string filePath)
{
var fileInfo = new System.IO.FileInfo(filePath);
var fileBytes = System.IO.File.ReadAllBytes(filePath);
//范围请求分支
if (Request.Headers.ContainsKey("Range"))
{
var rangeHeader = HttpContext.Request.Headers["Range"].ToString();
var range = rangeHeader.Replace("bytes=", "").Split('-');
long start = long.Parse(range[0]);
long end = range.Length > 1 ? long.Parse(range[1]) : fileInfo.Length - 1;
if (start >= fileInfo.Length || end >= fileInfo.Length || start > end)
{
return StatusCode(416); // Requested Range Not Satisfiable
}
var filePart = fileBytes.Skip((int)start).Take((int)(end - start + 1)).ToArray();
HttpContext.Response.Headers.Add("Content-Range", $"bytes {start}-{end}/{fileInfo.Length}");
HttpContext.Response.Headers.Add("Content-Length", filePart.Length.ToString());
return File(filePart, "text/plain", enableRangeProcessing: true);
}
//完整下载分支
return File(fileBytes, "text/plain");
}
如果要更完善一点,为某些下载器提供探测接口,那就还要实现一个head
方法。但这可能是很少用到的。
[HttpHead]
public IActionResult HeadFile(string filePath)
{
var fileInfo = new System.IO.FileInfo(filePath);
Response.Headers["Content-Length"] = fileInfo.Length.ToString();
return NoContent(); // 204 No Content
}