System.Text.Json을 사용하여 목록을 비 직렬화


11

많은 객체 목록을 포함하는 큰 json 파일을 요청한다고 가정 해 봅시다. 나는 그들이 한 번에 메모리에 들어가기를 원하지 않지만 오히려 하나씩 읽고 처리하려고합니다. 따라서 비동기 System.IO.Stream스트림을로 변환해야합니다 IAsyncEnumerable<T>. 이를 위해 새로운 System.Text.JsonAPI를 어떻게 사용 합니까?

private async IAsyncEnumerable<T> GetList<T>(Uri url, CancellationToken cancellationToken = default)
{
    using (var httpResponse = await httpClient.GetAsync(url, cancellationToken))
    {
        using (var stream = await httpResponse.Content.ReadAsStreamAsync())
        {
            // Probably do something with JsonSerializer.DeserializeAsync here without serializing the entire thing in one go
        }
    }
}

1
당신은 아마 같은 것을해야합니다 DeserializeAsync의 방법
파벨 Anikhouski

2
죄송합니다. 위의 방법은 전체 스트림을 메모리에로드하는 것 같습니다. 를 사용하여 비동기 적으로 데이터를 읽을 수 있습니다 Utf8JsonReader. 일부 github 샘플 과 기존 스레드 도 살펴보십시오.
Pavel Anikhouski

GetAsync전체 응답이 수신 되면 자체적으로 리턴됩니다 . SendAsync대신 HttpCompletionOption.ResponseContentRead와 함께 사용해야 합니다. 일단 당신이 JSON.NET의 JsonTextReader를 사용할 수 있습니다 . 이 문제가 보여주는 것처럼 이것을 사용 System.Text.Json하는 것은 쉽지 않습니다 . 기능은 사용할 수 없습니다 및 구조체를 사용하여 낮은 할당에서 그것을 구현하는 사소한 아니다
파나지오티스 Kanavos

청크에서 역 직렬화 문제는 직렬화를 해제 할 청크가 언제 있는지 알아야한다는 것입니다. 일반적인 경우에는이를 달성하기가 어렵습니다. 사전 파싱이 필요하며 이는 성능 측면에서 상당히 좋지 않은 트레이드 오프 일 수 있습니다. 일반화하기는 다소 어렵습니다. 그러나 JSON에 자체 제한을 적용하는 경우 (예 : "단일 객체가 파일에서 정확히 20 줄을 차지함") 파일을 비동기 적으로 청크 단위로 읽어 비동기 적으로 역 직렬화 할 수 있습니다. 그래도 이점을 얻으려면 거대한 json이 필요하다고 생각합니다.
DetectivePikachu

사람처럼 보이는 이미 대답 비슷한 질문을 여기에 전체 코드.
Panagiotis Kanavos

답변:


4

그렇습니다. 진정한 스트리밍 JSON (serializer) 직렬 변환기는 여러 곳에서 성능을 크게 향상시킵니다.

불행히도 System.Text.Json현재는이 작업을 수행하지 않습니다. 앞으로 일어날 지 잘 모르겠습니다. 그렇게되기를 바랍니다! JSON의 진정한 스트리밍 역 직렬화는 다소 어려운 것으로 판명되었습니다.

매우 빠른 Utf8Json이 지원 하는지 확인할 수 있습니다 .

그러나 요구 사항이 어려움을 제한하는 것처럼 보이기 때문에 특정 상황에 맞는 사용자 지정 솔루션이있을 수 있습니다.

아이디어는 한 번에 한 항목을 배열에서 수동으로 읽는 것입니다. 우리는 목록의 각 항목 자체가 유효한 JSON 객체라는 사실을 이용하고 있습니다.

[(첫 번째 항목) 또는 ,(다음 각 항목)을 지나서 수동으로 건너 뛸 수 있습니다 . 그런 다음 가장 좋은 방법은 .NET Core를 사용 Utf8JsonReader하여 현재 객체가 끝나는 위치를 결정하고 스캔 한 바이트를에 공급하는 것 JsonDeserializer입니다.

이 방법으로 한 번에 하나의 오브젝트를 약간만 버퍼링합니다.

그리고 우리는 성능을 이야기하고 있기 때문에에있는 PipeReader동안 입력을 얻을 수 있습니다. :-)


이것은 전혀 성능에 관한 것이 아닙니다. 그것은 이미 비동기 deserialization에 관한 것이 아닙니다 . JSON.NET의 JsonTextReader와 같은 방식으로 스트림에서 파싱 될 때 JSON 요소를 처리하는 스트리밍 액세스에 관한 것입니다.
Panagiotis Kanavos

Utf8Json의 관련 클래스는 JsonReader이며 작성자가 말했듯이 이상합니다. JSON.NET의 JsonTextReader와 System.Text.Json의 Utf8JsonReader는 동일한 기묘함을 공유합니다. 현재 요소의 유형을 반복하고 확인해야합니다.
Panagiotis Kanavos

@PanagiotisKanavos Ah, 예, 스트리밍 중입니다. 그것이 내가 찾던 단어입니다! "비동기"라는 단어를 "스트리밍"으로 업데이트하고 있습니다. 스트리밍을 원하는 이유는 메모리 사용을 제한하는 것으로 생각되며 이는 성능 문제입니다. 아마도 OP는 확인할 수 있습니다.
Timo

성능이 속도를 의미하지는 않습니다. deserializer가 아무리 빠르더라도 1M 항목을 처리해야하는 경우 RAM에 저장 하지 않고 모든 항목이 직렬화 해제 될 때까지 기다렸다가 첫 번째 항목을 처리 할 수 ​​있습니다.
Panagiotis Kanavos

시맨틱 스, 내 친구! 우리는 결국 같은 일을 달성하려고 다행입니다.
Timo

4

TL; DR 사소하지 않습니다.


누군가가 이미 스트림에서 버퍼를 읽고 Utf8JsonRreader에 공급 하는 구조체에 대한 전체 코드게시 한 것처럼 보입니다 . 코드도 사소하지 않습니다. 관련 질문은 여기에 있으며 대답은 여기에 있습니다 .Utf8JsonStreamReaderJsonSerializer.Deserialize<T>(ref newJsonReader, options);

그것은 충분하지 않습니다- HttpClient.GetAsync전체 응답이 수신 된 후에 만 ​​반환되며 본질적으로 메모리의 모든 것을 버퍼링합니다.

이를 피하려면 HttpClient.GetAsync (string, HttpCompletionOption)을와 함께 사용해야합니다 HttpCompletionOption.ResponseHeadersRead.

역 직렬화 루프는 취소 토큰도 확인하고 신호가 있으면 종료하거나 던집니다. 그렇지 않으면 전체 스트림이 수신되고 처리 될 때까지 루프가 진행됩니다.

이 코드는 관련 답변의 예를 기반으로 HttpCompletionOption.ResponseHeadersRead하며 취소 토큰을 사용 하고 확인합니다. 적절한 항목 배열이 포함 된 JSON 문자열을 구문 분석 할 수 있습니다. 예 :

[{"prop1":123},{"prop1":234}]

첫 번째 호출 jsonStreamReader.Read()은 배열의 시작 으로 이동하고 두 번째 호출 은 첫 번째 객체의 시작으로 이동합니다. 배열 끝 ( ])이 감지되면 루프 자체가 종료됩니다 .

private async IAsyncEnumerable<T> GetList<T>(Uri url, CancellationToken cancellationToken = default)
{
    //Don't cache the entire response
    using var httpResponse = await httpClient.GetAsync(url,                               
                                                       HttpCompletionOption.ResponseHeadersRead,  
                                                       cancellationToken);
    using var stream = await httpResponse.Content.ReadAsStreamAsync();
    using var jsonStreamReader = new Utf8JsonStreamReader(stream, 32 * 1024);

    jsonStreamReader.Read(); // move to array start
    jsonStreamReader.Read(); // move to start of the object

    while (jsonStreamReader.TokenType != JsonTokenType.EndArray)
    {
        //Gracefully return if cancellation is requested.
        //Could be cancellationToken.ThrowIfCancellationRequested()
        if(cancellationToken.IsCancellationRequested)
        {
            return;
        }

        // deserialize object
        var obj = jsonStreamReader.Deserialize<T>();
        yield return obj;

        // JsonSerializer.Deserialize ends on last token of the object parsed,
        // move to the first token of next object
        jsonStreamReader.Read();
    }
}

JSON 조각, AKA 스트리밍 JSON 일명 ... *

이벤트 스트리밍 또는 로깅 시나리오에서 개별 JSON 객체를 파일에 추가하는 것이 일반적입니다 (예 : 한 줄에 하나씩).

{"eventId":1}
{"eventId":2}
...
{"eventId":1234567}

유효한 JSON 문서는 아니지만 개별 조각은 유효합니다. 이는 빅 데이터 / 고 동시 시나리오에 몇 가지 장점이 있습니다. 새로운 이벤트를 추가하려면 전체 파일을 구문 분석하고 다시 작성하지 않고 파일에 새 줄을 추가하기 만하면됩니다. 처리 , 특히 병렬 처리는 다음 두 가지 이유로 더 쉽습니다.

  • 스트림에서 한 줄을 읽기만하면 개별 요소를 한 번에 하나씩 검색 할 수 있습니다.
  • 입력 파일은 라인 경계에 걸쳐 쉽게 분할 및 분할되어 각 부품을 별도의 작업자 프로세스 (예 : Hadoop 클러스터) 또는 애플리케이션의 다른 스레드에 공급할 수 있습니다. 그런 다음 첫 번째 줄 바꿈을 찾으십시오. 그 시점까지 모든 것을 별도의 작업자에게 공급하십시오.

StreamReader 사용

이를 수행하는 할당 방법은 TextReader를 사용하고 한 번에 한 줄을 읽고 JsonSerializer.Deserialize로 구문 분석하는 것입니다 .

using var reader=new StreamReader(stream);
string line;
//ReadLineAsync() doesn't accept a CancellationToken 
while((line=await reader.ReadLineAsync()) != null)
{
    var item=JsonSerializer.Deserialize<T>(line);
    yield return item;

    if(cancellationToken.IsCancellationRequested)
    {
        return;
    }
}

적절한 배열을 deserialize하는 코드보다 훨씬 간단합니다. 두 가지 문제가 있습니다.

  • ReadLineAsync 취소 토큰을받지 않습니다
  • 각 반복은 System.Text.Json을 사용하여 피하고 싶었던 것 중 하나 인 새 문자열을 할당 합니다.

ReadOnlySpan<Byte>JsonSerializer에 필요한 버퍼 를 생성하는 것으로 충분할 수도 있지만 , Deserialize는 쉽지 않습니다.

파이프 라인 및 SequenceReader

모든 위치를 피하려면 ReadOnlySpan<byte>스트림에서 를 가져와야 합니다. 이를 위해서는 System.IO.Pipeline 파이프와 SequenceReader 구조체를 사용해야합니다 . Steve Gordon의 SequenceReader 소개 에서는이 클래스를 사용하여 구분자를 사용하여 스트림에서 데이터를 읽는 방법에 대해 설명합니다.

불행히도 SequenceReaderref 구조체는 비동기 또는 로컬 메서드에서 사용할 수 없음을 의미합니다. 그래서 Steve Gordon은 자신의 기사에서

private static SequencePosition ReadItems(in ReadOnlySequence<byte> sequence, bool isCompleted)

항목을 읽는 방법은 ReadOnlySequence를 구성하고 종료 위치를 리턴하므로 PipeReader는 항목을 다시 시작할 수 있습니다. 불행히도 우리는 IEnumerable 또는 IAsyncEnumerable을 반환하고 싶습니다. 반복자 메서드는 좋아하지 in않거나 out매개 변수입니다.

우리는 역 직렬화 된 항목을 List 또는 Queue에서 수집하여 단일 결과로 반환 할 수 있지만 여전히 목록, 버퍼 또는 노드를 할당하고 반환하기 전에 버퍼의 모든 항목이 역 직렬화 될 때까지 기다려야합니다.

private static (SequencePosition,List<T>) ReadItems(in ReadOnlySequence<byte> sequence, bool isCompleted)

우리 는 반복자 메소드를 요구하지 않고 열거 가능한 것처럼 작동하고 비동기식으로 작동하며 모든 것을 버퍼링하지 않는 무언가 가 필요합니다.

IAsyncEnumerable을 생성하기 위해 채널 추가

ChannelReader.ReadAllAsyncIAsyncEnumerable을 반환합니다. 반복자로 작동 할 수없는 메서드에서 ChannelReader를 반환하고 캐싱없이 요소 스트림을 생성 할 수 있습니다.

Steve Gordon의 코드를 사용하여 채널을 사용하면 ReadItems (ChannelWriter ...) 및 ReadLastItem메서드가 제공됩니다. 첫 번째 항목은을 사용하여 한 번에 하나의 항목을 개행까지 읽습니다 ReadOnlySpan<byte> itemBytes. 에서 사용할 수 있습니다 JsonSerializer.Deserialize. 경우 ReadItems구분 기호를 찾을 수 없습니다 PipelineReader 스트림에서 다음 청크를 당길 수 있도록, 그것의 위치를 반환합니다.

마지막 청크에 도달하고 다른 구분자가 없으면 ReadLastItem`은 나머지 바이트를 읽고 역 직렬화합니다.

코드는 Steve Gordon의 코드와 거의 동일합니다. 콘솔에 쓰는 대신 ChannelWriter에 씁니다.

private const byte NL=(byte)'\n';
private const int MaxStackLength = 128;

private static SequencePosition ReadItems<T>(ChannelWriter<T> writer, in ReadOnlySequence<byte> sequence, 
                          bool isCompleted, CancellationToken token)
{
    var reader = new SequenceReader<byte>(sequence);

    while (!reader.End && !token.IsCancellationRequested) // loop until we've read the entire sequence
    {
        if (reader.TryReadTo(out ReadOnlySpan<byte> itemBytes, NL, advancePastDelimiter: true)) // we have an item to handle
        {
            var item=JsonSerializer.Deserialize<T>(itemBytes);
            writer.TryWrite(item);            
        }
        else if (isCompleted) // read last item which has no final delimiter
        {
            var item = ReadLastItem<T>(sequence.Slice(reader.Position));
            writer.TryWrite(item);
            reader.Advance(sequence.Length); // advance reader to the end
        }
        else // no more items in this sequence
        {
            break;
        }
    }

    return reader.Position;
}

private static T ReadLastItem<T>(in ReadOnlySequence<byte> sequence)
{
    var length = (int)sequence.Length;

    if (length < MaxStackLength) // if the item is small enough we'll stack allocate the buffer
    {
        Span<byte> byteBuffer = stackalloc byte[length];
        sequence.CopyTo(byteBuffer);
        var item=JsonSerializer.Deserialize<T>(byteBuffer);
        return item;        
    }
    else // otherwise we'll rent an array to use as the buffer
    {
        var byteBuffer = ArrayPool<byte>.Shared.Rent(length);

        try
        {
            sequence.CopyTo(byteBuffer);
            var item=JsonSerializer.Deserialize<T>(byteBuffer);
            return item;
        }
        finally
        {
            ArrayPool<byte>.Shared.Return(byteBuffer);
        }

    }    
}

DeserializeToChannel<T>메소드는 스트림 위에 파이프 라인 리더를 작성하고 채널을 작성한 후 청크를 구문 분석하고이를 채널로 푸시하는 작업자 태스크를 시작합니다.

ChannelReader<T> DeserializeToChannel<T>(Stream stream, CancellationToken token)
{
    var pipeReader = PipeReader.Create(stream);    
    var channel=Channel.CreateUnbounded<T>();
    var writer=channel.Writer;
    _ = Task.Run(async ()=>{
        while (!token.IsCancellationRequested)
        {
            var result = await pipeReader.ReadAsync(token); // read from the pipe

            var buffer = result.Buffer;

            var position = ReadItems(writer,buffer, result.IsCompleted,token); // read complete items from the current buffer

            if (result.IsCompleted) 
                break; // exit if we've read everything from the pipe

            pipeReader.AdvanceTo(position, buffer.End); //advance our position in the pipe
        }

        pipeReader.Complete(); 
    },token)
    .ContinueWith(t=>{
        pipeReader.Complete();
        writer.TryComplete(t.Exception);
    });

    return channel.Reader;
}

ChannelReader.ReceiveAllAsync()를 통해 모든 항목을 소비하는 데 사용할 수 있습니다 IAsyncEnumerable<T>.

var reader=DeserializeToChannel<MyEvent>(stream,cts.Token);
await foreach(var item in reader.ReadAllAsync(cts.Token))
{
    //Do something with it 
}    

0

자신의 스트림 리더에 능숙해야 할 것 같습니다. 바이트를 하나씩 읽고 오브젝트 정의가 완료되는 즉시 중지해야합니다. 실제로 꽤 저수준입니다. 따라서 전체 파일을 RAM에로드하지 않고 처리하는 부분을 수행하십시오. 답인 것 같습니까?


-2

아마도 Newtonsoft.Jsonserializer를 사용할 수 있습니까? https://www.newtonsoft.com/json/help/html/Performance.htm

특히 다음 섹션을 참조하십시오.

메모리 사용량 최적화

편집하다

JsonTextReader에서 값을 역 직렬화 할 수 있습니다.

using (var textReader = new StreamReader(stream))
using (var reader = new JsonTextReader(textReader))
{
    while (await reader.ReadAsync(cancellationToken))
    {
        yield return reader.Value;
    }
}

그것은 질문에 대답하지 않습니다. 이것은 성능에 관한 것이 아니라 메모리에 모든 것을로드 하지 않고 액세스 스트리밍 하는 것에 관한 것입니다.
Panagiotis Kanavos

관련 링크를 열었거나 방금 생각한 것을 말했습니까? 내가 언급 한 섹션에서 보낸 링크에는 JSON을 스트림에서 직렬화 해제하는 방법에 대한 코드 스 니펫이 있습니다.
Miłosz Wieczorek

질문을 다시 읽으십시오 -OP는 메모리의 모든 것을 직렬화 해제 하지 않고 요소를 처리하는 방법을 묻습니다 . 스트림에서 읽을뿐만 아니라 스트림에서 오는 것만 처리합니다. I don't want them to be in memory all at once, but I would rather read and process them one by one.JSON.NET의 관련 클래스는 JsonTextReader입니다.
Panagiotis Kanavos

어쨌든 링크 전용 답변은 좋은 답변으로 간주되지 않으며 해당 링크에는 OP의 질문에 대한 답변이 없습니다. JsonTextReader에 대한 링크가 더 좋을 것이다
파나지오티스 Kanavos
당사 사이트를 사용함과 동시에 당사의 쿠키 정책개인정보 보호정책을 읽고 이해하였음을 인정하는 것으로 간주합니다.
Licensed under cc by-sa 3.0 with attribution required.