前陣子在在 ASP.NET Web API 使用 IAsyncEnumerable 並串流至 JavaScript分享到使用 IAsyncEnumerable<T>
來處理串流資料,但是當時的範例的回傳值是 JSON 物件,這造成前端在解析資料時,必須處理 IAsyncEnumerable<T>
被 JSON 序列化後的格式,這讓前端不得不做一些額外處理。這篇將重新思考這段處理方式,在針對「模擬聊天情境,即時的一字字依序輸出在網頁上」這個目標下,重新設計 API 的回傳方式,讓前端可以更容易的處理串流資料。
本篇基於在 ASP.NET Web API 使用 IAsyncEnumerable 並串流至 JavaScript的內容及範例程式碼,請先參考這篇文章。
API 端
[ApiController]
[Route("[controller]")]
public class StreamController : ControllerBase
{
[HttpGet]
[Route("object")]
public async IAsyncEnumerable<Demo> GetObject()
{
for (int i = 0; i < 10; i++)
{
await Task.Delay(TimeSpan.FromSeconds(1));
yield return new Demo { Id = i, Name = Guid.NewGuid().ToString() };
}
}
}
上面的程式碼是原本的做法,在 API 回傳資料的時候 IAsyncEnumerable<Demo>
經過 ASP.NET Core WebAPI 的序列化程序,將回傳值進行 JSON 序列化,這樣的做法會造成前端在接收資料時,必須要處理 IAsyncEnumerable<Demo>
被 JSON 序列化後的格式,這讓前端不得不做一些額外處理(詳閱上篇文章)。
這引發了一次思考,為了達到「模擬聊天情境,即時的一字字依序輸出在網頁上」的目標,我們真的需要將物件類型的資料嗎?如果我們將資料改成純文字,這樣的話,前端就不需要再處理 IAsyncEnumerable<Demo>
被 JSON 序列化後的格式,而是直接處理純文字即可。
不過如果在既有架構下,當我們直接改用 IAsyncEnumerable<string>
作為回傳類型,這樣是沒有用的,如下的程式碼:
[HttpGet]
[Route("string")]
public async IAsyncEnumerable<string> GetString()
{
for (int i = 0; i < MAX; i++)
{
await Task.Delay(TimeSpan.FromSeconds(1));
yield return Guid.NewGuid().ToString();
}
}
這樣的寫法不會脫離回傳值被 ASP.NET Core WebAPI 進行 JSON 序列化的動作,回傳的結果會像下面這樣,一行是一次傳送到前端的資料。
["c97eed80-0948-42c9-85e4-0a30bb9df613"
,"ae09674e-b706-4a69-9505-43be9f04e112"
,"2f8d6fe4-cb2d-4796-925a-a875e8b61adf"
,"ca15f991-7091-4529-be1a-f2b06abd8fc7"]
在原本的寫法中,回傳值有可能不會一筆一筆回傳,而是一次回傳 n 筆,這取決於 Yield 的內部處理。
我們需要一個做法來避免回傳值被進行 JSON 序列化的動作。
要達成這點,我們可以使用 IActionResult
作為回傳類型,並在回傳時,使用 Response.WriteAsync()
來逐筆將資料加到回應內容中,這樣的做法,我可以完全掌控回傳值的長相。
再搭配呼叫某個回傳值是 IAsyncEnumerable<string>
的方法(如下的 Streaming()
),將每次 yield return
的資料寫入 Response
內容中,並在全部資料完成寫入後,告訴 Response
我完成了。
如此一來,前端就可以在同一個 HTTP 連線中持續獲得資料,直到回應結束。
程式碼如下:
[HttpGet]
[Route("just-string")]
public async Task<IActionResult> GetJustString()
{
// 設定回應的 Content-Type
Response.Headers.Append("Content-Type", "text/plain");
// 逐筆將資料加到回應內容中
await foreach (var item in Streaming())
{
if (item is null) continue;
await Response.WriteAsync(item);
}
// 完成回應
await Response.CompleteAsync();
// 最終返回一個空結果
return new EmptyResult();
async IAsyncEnumerable<string> Streaming()
{
for (int i = 0; i < MAX; i++)
{
await Task.Delay(TimeSpan.FromSeconds(1));
yield return Guid.NewGuid().ToString();
}
}
}
這樣的做法,回傳的結果會像下面這樣,一行是一次傳送到前端的資料:
c97eed80-0948-42c9-85e4-0a30bb9df613
ae09674e-b706-4a69-9505-43be9f04e112
2f8d6fe4-cb2d-4796-925a-a875e8b61adf
ca15f991-7091-4529-be1a-f2b06abd8fc7
JavaScript 端
先前在 JavaScript 這邊需要針對 JSON 的格式做一些處理,但是現在我們改用純文字的方式,因此前端就不需要再額外的處理,也就是在執行 callback()
的時候不需要呼叫 tolerantParse()
來將收到的資料洗乾淨。
function stream(callback) {
const myHeaders = new Headers();
myHeaders.append('Content-Type', 'application/json');
const requestOptions = {
method: 'GET',
headers: myHeaders,
redirect: 'follow',
};
fetch('http://localhost:5213/stream/object', requestOptions)
.then((response) => {
const reader = response.body.getReader();
// 處理資料流中的每個資料區塊
reader.read().then(function pump({ done, value }) {
// "done" 是布林值,代表該資料流是否結束
// "value" 是 Uint8Array,代表每個資料區塊的內容值
// 當收到資料流結束的訊號,則關閉此資料流
if (done) return;
// 將 Uint8Array 轉成 string,然後交給 callback 函式處理
let data = new TextDecoder().decode(value);
callback(data);
// 讀取下一段資料區塊
return reader.read().then(pump);
});
})
.catch((error) => console.log('error', error));
}
本篇完整範例程式碼請參考 poychang/demo-AsyncEnumerableApi。
後記
這篇的重點在於 API 端,以及思考回傳的內容應該長什麼樣。
在 API 端,我們透過直接將所要提供的文字資料寫入 HTTP Response,來達到避免資料被 ASP.NET Core 內建的序列化機制處理,讓前端可以更單純的處理串流文字的內容即可。
而之所以會延伸出這樣的做法,是因為在思考串流類型的資料該如何呈現。在網路上看到一些對 OpenAI 界接 Stream 的對話回應資料的分享,不少人都是特別寫一段解析純文字成 JSON 的方法,不論是透過正規表達式或是其他方式,這樣的做法讓我覺得奇怪,也不是不對,只是這樣除了會增加前端處理內容的負擔,而且 OpenAI 的 Completion API 所回應的內容中,許多資訊是前端不需要知道的,例如回應的 ID、建立時間、所使用的模型名稱,等等這類的資訊。
像是 LLM 的對話應用,呼叫的內容重點應該擺在回應的內容上,也就對話中的文字內容。至於其他資訊如果真的需要,就透過其他方式取得吧。
參考資料: