使用 API 的方式來取得 JSON 資料是現代化系統常見的方式,在接到 JSON 字串資料後,序列化成強型別的物件能讓後續處理變得容易,不過這樣的處理方式在大量資料的情境下,容易因為 JSON 字串資料的關係,造成記憶體耗用的比較兇,畢竟儲存字串本身會佔據記憶體空間。這時候如果在取回 HTTP Response 的時候,直接使用 Stream 的格式來處理 JSON 資料,就能有效的降低記憶體的使用量,這篇文章將會介紹如何使用 Stream 的方式來處理 JSON 資料。
假設我們有個 API 可以取得 JSON 資料,如下:
var jsonFileUrl = "https://blog.poychang.net/apps/mock-json-data/json-array-data-10.json";
並假設我們已經有一個 DataModel
對應上述 API 所提供的 JSON 資料的格式。
過去的處理方式
過去我們會使用 HttpClient
來取得 JSON 資料字串,再交由 Newtonsoft.Json 或 System.Text.Json 來處理反序列化的動作,將 JSON 字串轉換成強型別的物件,程式碼如下:
var httpClient = new HttpClient();
var httpRequest = new HttpRequestMessage(HttpMethod.Get, jsonFileUrl);
using var response = await httpClient.SendAsync(httpRequest);
var dataString = await response.Content.ReadAsStringAsync();
var data = JsonConvert.DeserializeObject<IEnumerable<DataModel>>(dataString);
也就是說,HttpClient
取得 HttpResponseMessage
(也就是上述程式碼的 response.Content
)的之後,使用 ReadAsStringAsync()
將內容以字串的方式讀出來,再使用 JsonConvert.DeserializeObject()
做反序列化成強行別物件。
使用 Stream 的處理方式
然而 HttpClient
有提供另一個讀取資料的方法 ReadAsStreamAsync()
,可以將 HttpResponseMessage
的內容以 Stream
的方式讀出來,這樣就能有效的降低記憶體的使用量,程式碼如下:
var httpClient = new HttpClient();
var httpRequest = new HttpRequestMessage(HttpMethod.Get, jsonFileUrl);
using var response = await httpClient.SendAsync(httpRequest, HttpCompletionOption.ResponseHeadersRead);
var dataStream = await response.Content.ReadAsStreamAsync();
HttpClient 讀取技巧
由於我們是想要使用 Stream 資料流的方式來讀取資料,在讀取的過程中是邊讀邊處理,而非一次取得完整的資料後再進行操作。
因此在過程中,我們可以在 SendAsync()
中加上 HttpCompletionOption.ResponseHeadersRead
參數,讓 HttpClient
在取得 HttpResponseMessage
的 Header 後就表示已經完成呼叫,讓後續要處理的動作可以立刻接續進行,而不用等到 HttpClient 收到完整的回應後再進行後續處理,這樣的好處是效能會稍微好一些,同時因為 HttpClient 不需要對回應內容做 buffer 因此也能降低一些記憶體的使用量。
HttpCompletionOption
有以下兩個列舉值,詳細內容請參考 HttpCompletionOption 列舉官方文件。
ResponseContentRead
在讀取包括內容的完整回應之後,即完成操作。ResponseHeadersRead
在讀取到可使用的回應 Header 標頭後,就代表完成作業。請注意,這尚未讀取回應內容。
對 Stream 做 JSON 反序列化
在取得到回應的 Stream 資料之後,我們可以使用 Newtonsoft.Json 來對 Stream 的資料做反序列化,程式碼如下:
TResult ReadJsonStream<TResult>(Stream stream)
{
using var reader = new StreamReader(stream);
using var jsonReader = new Newtonsoft.Json.JsonTextReader(reader);
return new Newtonsoft.Json.JsonSerializer().Deserialize<TResult>(jsonReader)!;
}
這邊我們使用 Newtonsoft.Json.JsonSerializer()
來對 Stream 的資料做反序列化,這裡會將整個 Stream 視為一個合法的 JSON 物件(Object 或 Array),例如:
{ "id": 1, "name": "Poy" }
// 或是
[{ "id": 1, "name": "Poy" }, { "id": 2, "name": "Chang" }]
而有些情況下,這個 Stream 的資料是會串流多個 JSON 物件,仔細看下面的範例,他們個別都有兩個合法的 JSON 物件,但合併在一起看卻是不合法的 JSON 格式:
{ "id": 1, "name": "Poy" }{ "id": 2, "name": "Chang" }
// 或是
[{ "id": 1, "name": "Poy" }, { "id": 2, "name": "Chang" }][{ "id": 3, "name": "Foo" }, { "id": 4, "name": "Bar" }]
像這樣的 JSON 資料流格式,還是可以使用 Newtonsoft.Json.JsonTextReader
來讀取 Stream 的資料,只要將 JsonTextReader
的 SupportMultipleContent
設定為 true
,並且搭配使用 yield return
來回傳反序列化的結果,就能讓 JsonTextReader
連續讀取到多筆資料,範例程式碼如下:
IEnumerable<TResult> ReadJsonStreamMultipleContent<TResult>(Stream stream)
{
using var reader = new StreamReader(stream);
using var jsonReader = new Newtonsoft.Json.JsonTextReader(reader)
{
SupportMultipleContent = true
};
var serializer = new Newtonsoft.Json.JsonSerializer();
while (jsonReader.Read())
{
yield return serializer.Deserialize<TResult>(jsonReader)!;
}
}
請注意,因為回傳的是”多個” JSON 物件,因此回傳的型別是
IEnumerable<TResult>
,而不是TResult
。
除了使用 Newtonsoft.Json 來處理 Stream 的 JSON 資料外,我們也可以使用 .NET 內建的 System.Text.Json 來對 Stream 做相同的反序列化操作,程式碼如下:
TResult ReadJsonStream<TResult>(Stream stream)
{
return System.Text.Json.JsonSerializer.DeserializeAsync<TResult>(stream).GetAwaiter().GetResult()!;
}
你可能會注意到在
return
的最後有加上!
符號,這是因為範例專案有開啟Nullable
因此會對回傳 null 的值做出警告,這時候可以使用! 寬恕運算子來忽略警告。
效能比較
這裡我們做 4 種效能測試,分別是使用 Newtonsoft.Json 或 System.Text.Json 分別對 String 或 Stream 做反序列化,並且使用 BenchmarkDotNet 來計算執行時間與記憶體占用量,測試結果如下:
可以看到,使用 Stream 的方式來做反序列化,效能與記憶體使用量都比較好,而使用 System.Text.Json
的效能與記憶體使用量都比較好,因此在使用 HttpClient
讀取大量的 JSON 資料時,可以考慮使用 Stream 的方式來讀取,並且使用 System.Text.Json
來做反序列化。
本篇完整範例程式碼請參考 poychang/JsonStreamDeserializeBenchmark。
後記
透過這篇所提到的技巧,在接收並處理大尺寸的 JSON 資料時,可以有效的降低記憶體使用量,並且提升效能,這對於呼叫並處理大量資料的應用程式來說,是相當好用。
參考資料: