耗时很长的请求怎么处理?比如数据量大的。业务逻辑处理时间太久,以至于响应超时
这里的超时响应指的是ReadTimeOut
,即发送请求内容完毕到接收响应数据开始的这段时间。普通HTTP请求可能在这段时间没有响应超时。
HTTP分块传输(Chunked Transfer Encoding)中每个数据块的到达都会刷新ReadTimeOut
。服务器推送事件(SSE)中服务器会自动发送心跳消息刷新ReadTimeOut
。由于这种分块或流式传输的方式每次消息处理的业务量和数据量较小,可以减少超时。
这两种只是让请求方尽快看到结果,数据出来一次就推送一次,并不能减少全部数据处理完毕的时间。而js可以收到一次回调我们的代码,打印或者处理一次,而不是收到全部所有数据后再将控制权交给我们的代码。要分批返回数据,就要求服务端的业务逻辑代码不要一次性处理所有数据,而是分批处理或查询。
http报文分块传输
HTTP/1.1 200 OK
Content-Type: application/json
Transfer-Encoding: chunked4A
[{"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
头部表明这是一个分块传输响应 - 4A、4C、和42是各个块的字节大小(十六进制形式),分别对应第一、二、三块数据的长度。
- JSON数据紧跟在块大小后。
- 每个块后面跟一个CRLF。
- 0后跟一个CRLF,表示数据结束。
这一个的问题在于服务器发送和浏览器接收是什么形式?什么表现?我需要试一下。
- 服务端
[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)
,然后开始响应数据。绿色部分走完,浏览器得到响应第一部分数据,进入蓝色部分。
这种只能解决传输慢的问题,让接收方尽早看到数据,但不能加快全部数据响应完成时间。
SSE流式传输
流式传输服务端需要设置特定响应头,然后保持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。
HTTP范围请求
范围请求似乎不是我们手动直接处理,而是浏览器和服务器自动完成的。比如大文件下载断点续传。这种不在乎超时问题,似乎不应该纳入此次讨论范畴。
但是我好奇的是,范围请求的流程。浏览器如何决定下载一个压缩包时发送范围请求还是普通请求?浏览器再最开始如何知道范围大小?这似乎有个探测阶段才行,那么浏览器和服务器是如何互动的?如果有探测,那么服务器怎么知道这是一个探测请求,而不是一个下载请求?
确实有一个探测阶段,使用head
方法,而不是常规的get
post
,仅获取文件大小信息而不下载内容。
- 浏览器发送的 HEAD 请求
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
,以便再暂停下载之后,再次点击下载时决定能否换成发送范围请求。
- 对于静态文件,通常web服务器内置实现了范围请求的响应。
- 对于由控制器接口提供的文件下载,需要我们自己实现这个action的范围下载逻辑,即取头部范围字段,计算偏移量,设置头部,响应码,返回相应部分数据。
所以控制器接口考虑断点续传时,就要加一个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
}