大請求、請求超時問題

ggtc發表於2024-08-31

耗時很長的請求怎麼處理?比如資料量大的。業務邏輯處理時間太久,以至於響應超時

這裡的超時響應指的是ReadTimeOut,即傳送請求內容完畢到接收響應資料開始的這段時間。普通HTTP請求可能在這段時間沒有響應超時。

HTTP分塊傳輸(Chunked Transfer Encoding)中每個資料塊的到達都會重新整理ReadTimeOut。伺服器推送事件(SSE)中伺服器會自動傳送心跳訊息重新整理ReadTimeOut。由於這種分塊或流式傳輸的方式每次訊息處理的業務量和資料量較小,可以減少超時。

這兩種只是讓請求方儘快看到結果,資料出來一次就推送一次,並不能減少全部資料處理完畢的時間。而js可以收到一次回撥我們的程式碼,列印或者處理一次,而不是收到全部所有資料後再將控制權交給我們的程式碼。要分批返回資料,就要求服務端的業務邏輯程式碼不要一次性處理所有資料,而是分批處理或查詢。

http報文分塊傳輸

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頭部表明這是一個分塊傳輸響應
  • 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秒延遲

image

看這個時間解析,第一次讀取時,遇到第一個Task.Delay(2000),然後開始響應資料。綠色部分走完,瀏覽器得到響應第一部分資料,進入藍色部分。

image

這種只能解決傳輸慢的問題,讓接收方儘早看到資料,但不能加快全部資料響應完成時間。

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);
};

image

不過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
}

相關文章