Microsoft MVP성태의 닷넷 이야기
글쓴 사람
정성태 (techsharer at outlook.com)
홈페이지
첨부 파일

.NET 5에서의 네트워크 라이브러리 개선 (1) - HTTP 관련

이번엔 다음의 글을 정리해보겠습니다. ^^

.NET 5 Networking Improvements
; https://devblogs.microsoft.com/dotnet/net-5-new-networking-improvements/

내용이 길어, HTTP 관련 글만 이번에 포함하고 나머지는 두 번째 글에서 설명하겠습니다.





#1 에러 처리 개선

HttpClient는 지난 3.1에서 요청에 대한 시간 만료와 취소에 대한 구분을 지원했습니다. 간단한 테스트를 위해 서버를 이렇게 만들고,

using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.AspNetCore.Http;
using System.Threading;
using System.Threading.Tasks;

public class Program
{
    public static void Main(string[] args)
    {
        CreateHostBuilder(args).Build().Run();
    }

    public static IHostBuilder CreateHostBuilder(string[] args) =>
        Host.CreateDefaultBuilder(args)
            .ConfigureWebHostDefaults(webBuilder =>
            {
                webBuilder.UseStartup();
            });
}

public class Startup
{
    public void ConfigureServices(IServiceCollection services) { }

    public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
    {
        app.UseRouting();
        app.UseEndpoints(endpoints =>
        {
            endpoints.MapGet("/", async context =>
            {
                if (context.Request.Query.ContainsKey("delay") == true)
                {
                    int seconds = int.Parse(context.Request.Query["delay"]);
                    await Task.Delay(seconds * 1000);
                }

                await context.Response.WriteAsync("Hello World!");
            });
        });
    }
}

클라이언트는 CancellationTokenSource.Cancel 메서드의 호출 유무에 따라 TaskCanceledException 처리를 구분해 다음과 같이 할 수 있습니다.

using System;
using System.Net.Http;
using System.Threading;
using System.Threading.Tasks;

class Program
{
    private static readonly HttpClient _client = new HttpClient()
    {
        Timeout = TimeSpan.FromSeconds(10)
    };

    static async Task Main()
    {
        var cts = new CancellationTokenSource();

        new Thread(() =>
        {
            Thread.Sleep(3000);
            cts.Cancel();
        }).Start();

        try
        {
            using HttpResponseMessage response = await _client.GetAsync("http://localhost:15000/", cts.Token);
        }
        // If the token has been canceled, it is not a timeout.
        catch (TaskCanceledException ex) when (cts.IsCancellationRequested)
        {
            // Handle cancellation.
            Console.WriteLine("Canceled: " + ex.Message);
        }
        catch (TaskCanceledException ex)
        {
            // Handle timeout.
            Console.WriteLine("Timed out: " + ex.Message);
        }
    }
}

.NET 5 이후부터는 위의 구분과 함께 time-out일 경우 InnerException에 TimeoutException을 명시적으로 전달하도록 바뀌었으므로 다음과 같이 처리하는 것도 가능합니다.

try
{
    using var response = await _client.GetAsync("http://localhost:15000/", cts.Token);
}
catch (TaskCanceledException ex) when (ex.InnerException is TimeoutException)
{
    Console.WriteLine("Timed out: " + ex.Message);
}
catch (TaskCanceledException ex)
{
    Console.WriteLine("Canceld: " + ex.Message);
}

또 다른 개선 사항으로는 HttpRequestException 예외에 추가한 StatusCode 속성입니다.

HttpRequestException.StatusCode Property
; https://docs.microsoft.com/en-us/dotnet/api/system.net.http.httprequestexception.statuscode

이에 대한 예제를 위해 기존의 404 NOT FOUND를 보겠습니다. 기본적으로 NOT FOUND는 예외를 발생시키지 않는데요,

try
{
    using HttpResponseMessage response = await _client.GetAsync("http://localhost:5000/notfound", cts.Token);
}
catch (HttpRequestException ex) // 404 NOT FOUND로 인한 예외는 발생하지 않음
{
    Console.WriteLine("HttpRequestException: " + ex.Message);
}

이것을 예외로 처리하려면 HttpResponseMessage 타입의 EnsureSuccessStatusCode 메서드를 호출해 주어야 합니다.

try
{
    using HttpResponseMessage response = await _client.GetAsync("http://localhost:5000/notfound", cts.Token);
    response.EnsureSuccessStatusCode(); // HTTP 상태 코드가 2xx이 아닌 경우 예외 처리
}
catch (HttpRequestException ex)
{       
    Console.WriteLine("HttpRequestException: " + ex.Message);
}

여기서 문제는, EnsureSuccessStatusCode로 인한 예외 발생 시 404 NOT FOUND로 인한 예외인지에 대한 구분을 할 수 없다는 것입니다. 억지로 하려면 Exception.Message 속성에 "Response status code does not indicate success: 404 (Not Found)."라고 담긴 문자열로부터 유추해야 하는데, 이에 대한 보완으로 .NET 5.0부터 추가된 StatusCode를 이용하는 경우 정확한 오류 코드를 짚어낼 수 있습니다.

try
{
    using HttpResponseMessage response = await _client.GetAsync("http://localhost:5000/notfound", cts.Token);
    response.EnsureSuccessStatusCode(); // HTTP 상태 코드가 2xx이 아닌 경우 예외 처리
}
catch (HttpRequestException ex) when (ex.StatusCode == HttpStatusCode.NotFound) /* .NET 5부터 가능 */
{       
    Console.WriteLine("Not found: " + ex.Message);
}

또한, StatusCode를 받아들이는 HttpRequestException 생성자가 .NET 5에 추가되면서 다음과 같은 처리도 가능해졌습니다.

try
{
    using HttpResponseMessage response = await _client.GetAsync("http://localhost:5000/notfound", cts.Token);

    if (response.StatusCode >= HttpStatusCode.BadRequest)
    {
        throw new HttpRequestException("BAD REQUEST", inner: null, response.StatusCode);  /* .NET 5부터 가능 */
    }
}
catch (HttpRequestException ex) when (ex.StatusCode == HttpStatusCode.NotFound)  /* .NET 5부터 가능 */
{       
    Console.WriteLine("Not found: " + ex.Message);
}

참고로, HttpClient의 메서드 중 HttpResponseMessage가 반환값이 아니어서 EnsureSuccessStatusCode 메서드를 호출할 수 없는 GetStringAsync, GetByteArraysAsync, GetStreamAsync 메서드 등이 있는데, 이들은 내부적으로 EnsureSuccessStatusCode 메서드를 호출해 주고 있으므로 404 등의 반환인 경우 GetAsync와는 달리 항상 예외가 발생하게 됩니다.





#2 다중 플랫폼에서의 일관성

초기 .NET Core는 플랫폼 별로 상이한 HTTP Handler에 의존하고 있었습니다.

  • WinHttpHandler: 윈도우 환경에서 WinHTTP 구성 요소에 기반
  • CurlHandler: 리눅스/맥 환경에서 libcurl 구성 요소에 기반

따라서, HttpClient의 동작 방식이 플랫폼 별로 달라질 수 있었는데 이런 문제를 .NET Core 2.1부터 (순수 managed 코드로만 구현한) SocketsHttpHandler를 공통 제공함으로써 해결했고, .NET 5부터는 아예 플랫폼 별로 구현된 Handler를 System.Net.Http.dll로부터 제거했다고 합니다. 이로 인해, 기존에는 .NET Core 2.1에서 SocketsHttpHandler가 아닌 기존 Handler를 사용하려는 경우 "System.Net.Http.UseSocketsHttpHandler" 설정으로 가능했는데,

System.Net.Http.UseSocketsHttpHandler
; https://docs.microsoft.com/en-us/dotnet/api/system.net.http.socketshttphandler?view=net-5.0#remarks

// 방법 1: 코드로 설정
AppContext.SetSwitch("System.Net.Http.UseSocketsHttpHandler", false);

// 방법 2: runtimeconfig.json에 설정
"runtimeOptions": {
  "configProperties": {
      "System.Net.Http.UseSocketsHttpHandler": false
  }
}

// 방법 3: 환경 변수 설정
SET DOTNET_SYSTEM_NET_HTTP_USESOCKETSHTTPHANDLER=0

.NET 5부터는 저렇게 설정해도 구현체가 제거되었기 때문에 SocketsHttpHandler가 무조건 사용됩니다. 만약, 그래도 사용하길 원한다면 Nuget으로부터 System.Net.Http.WinHttpHandler를 참조 추가하고,

Install-Package System.Net.Http.WinHttpHandler

다음과 같이 명시적으로 해당 handler를 사용하겠다고 코드 변경을 해야 합니다.

class Program
{
    private static readonly HttpClient _client = new HttpClient(new WinHttpHandler());

    static async Task Main()
    {
        using var response = await _client.GetAsync("http://localhost:5001/");
    }
}





#3 SocketsHttpHandler의 확장성

.NET 5부터 SocketsHttpHandler에는 2가지 확장을 제공합니다.


우선 ConnectCallback은 이름에서 유추할 수 있듯이 새로운 TCP 연결이 있을 때마다 호출되는데, 이때 연결 대상의 주소를 식별할 수 있는 DnsEndPoint와 HTTP 요청 메시지를 담고 있는 HttpRequestMessage를 함께 전달받습니다.

따라서, 이를 통해 임의적으로 DNS 주소 풀이를 하거나 사용하게 될 소켓마다 공통 설정이 필요한 작업 등을 콜백에서 수행할 수 있고, 혹은 순수하게 새로운 연결이 발생했음을 감지하는 용도로도 사용할 수 있습니다. 재미있는 것은, ConnectCallback이 요구하는 반환 형식은 ValueTask<Stream>이기 때문에 Stream 구문에만 맞으면 됩니다. 즉, 대상이 프로세스 내부인 경우 소켓이 아닌 별도의 in-process 통신 방식으로 우회하는 등의 작업도 가능합니다. 실제로 단순히 Stream만을 구현해 다음과 같이 ConnectCallback 테스트를 할 수 있습니다.

using System;
using System.IO;
using System.Net.Http;
using System.Net.Sockets;
using System.Text;
using System.Threading;
using System.Threading.Tasks;

namespace s3_shh_extension
{
    class Program
    {
        private static readonly HttpClient _client;

        static Program()
        {
            SocketsHttpHandler handler = new SocketsHttpHandler();
            handler.ConnectCallback = MemoryConnectAsync;
            _client = new HttpClient(handler);
        }

        private static async ValueTask<Stream> MemoryConnectAsync(SocketsHttpConnectionContext context, CancellationToken cancellationToken)
        {
            string fakeResponse = @"HTTP/1.1 200 OK
Server: MemoryStream
Content-Type: text/html; charset=UTF-8
Content-Length: 4
Connection: close

test
";
            MyStream stream = new MyStream(fakeResponse);
            return stream;
        }

        static async Task Main(string[] args)
        {
            using HttpResponseMessage response = await _client.GetAsync("http://localhost:5000/notfound", CancellationToken.None);
            Console.WriteLine(await response.Content.ReadAsStringAsync());
        }
    }

    public class MyStream : Stream
    {
        string _fakeResponse;

        public MyStream(string fakeResponse)
        {
            _fakeResponse = fakeResponse;
        }

        public override bool CanRead => true;

        public override bool CanSeek => false;

        public override bool CanWrite => true;

        public override long Length => throw new NotImplementedException();

        public override long Position { get => throw new NotImplementedException(); set => throw new NotImplementedException(); }

        public override void Flush()
        {
        }

        public override int Read(byte[] buffer, int offset, int count)
        {
            byte [] textBuffer = Encoding.UTF8.GetBytes(_fakeResponse);
            Array.Copy(textBuffer, buffer, textBuffer.Length);

            return textBuffer.Length;
        }

        public override long Seek(long offset, SeekOrigin origin)
        {
            throw new NotImplementedException();
        }

        public override void SetLength(long value)
        {
            throw new NotImplementedException();
        }

        public override void Write(byte[] buffer, int offset, int count)
        {
            string text = Encoding.UTF8.GetString(buffer, offset, count);
            Console.WriteLine(text);
        }
    }
}

/* 출력 결과
GET /notfound HTTP/1.1
Host: localhost:5000


test
*/

대개의 경우 저런 식으로 확장을 하는 경우는 드물 테고, 현실적으로 사용하려는 소켓에 대한 공통 설정 코드를 넣어두는 용도로 사용될 텐데요, 아래의 예는 HttpClient가 사용하는 소켓에 대해 Nagle 알고리즘을 끄는 방법을 제시합니다.

private static async ValueTask<Stream> DefaultConnectAsync(SocketsHttpConnectionContext context, CancellationToken cancellationToken)
{
    // The following socket constructor will create a dual-mode socket on systems where IPV6 is available.
    Socket socket = new Socket(SocketType.Stream, ProtocolType.Tcp);
    // Turn off Nagle's algorithm since it degrades performance in most HttpClient scenarios.
    socket.NoDelay = true;

    try
    {
        await socket.ConnectAsync(context.DnsEndPoint, cancellationToken).ConfigureAwait(false);
        // The stream should take the ownership of the underlying socket,
        // closing it when it's disposed.
        return new NetworkStream(socket, ownsSocket: true);
    }
    catch
    {
        socket.Dispose();
        throw;
    }
}

ConnectCallback이 연결 전의 후킹이라면, PlaintextStreamFilter 확장은 연결이 완료된 후 최초의 요청 데이터가 전달되기 전의 후킹을 지원합니다. 아래는 아무런 변경도 하지 않고 메시지 내용만 출력하는 예제 코드입니다.

SocketsHttpHandler handler = new SocketsHttpHandler();
handler.PlaintextStreamFilter = PlaintextStreamFilter;
_client = new HttpClient(handler);

private static ValueTask<Stream> PlaintextStreamFilter(SocketsHttpPlaintextStreamFilterContext context, CancellationToken cancellationToken)
{
    Console.WriteLine($"Request {context.InitialRequestMessage} --> negotiated version {context.NegotiatedHttpVersion}");
    return ValueTask.FromResult(context.PlaintextStream);
}

/* 출력 결과
Request Method: GET, RequestUri: 'http://localhost:5000/', Version: 1.1, Content: , Headers:
{
} --> negotiated version 1.1
*/





#4 동기 방식으로 동작하는 HttpClient.Send 메서드 추가

비록 비동기 버전을 추천하기는 하지만, 간혹 무리하게 동기식으로 호출하기 위해 HttpClient.SendAsync 류가 반환하는 Task로 블록킹 메서드를 호출하는 사례가 있는데 자칫 데드락(deadlock)의 위험을 내포하므로 이런 경우를 위해 동기 버전을 제공하게 되었다고 합니다.

대신 아래와 같은 제약이 있습니다.

  • HTTP/1.1 통신만 가능
  • ConnectCallback 확장과 함께 사용할 수 없음
  • .NET에서 기본 제공하는 SocketsHttpHandler/HttpContent가 아닌 사용자 정의 handler/HttpContent를 사용하는 경우 그것의 동기 버전에 해당하는 HttpMessageHandler.Send/HttpContent.SerializeToStream이 구현되어 있어야 함.




(첨부 파일은 이 글의 예제 코드를 포함합니다.)

참고로, 요즘 제가 자주 가는 사이트가 있습니다.

닷넷데브
; https://forum.dotnetdev.kr/

여러 가지 닷넷 관련 소식들이 올라오기 때문에 한 번씩 들러보는 것을 추천합니다. ^^




[이 글에 대해서 여러분들과 의견을 공유하고 싶습니다. 틀리거나 미흡한 부분 또는 의문 사항이 있으시면 언제든 댓글 남겨주십시오.]

[연관 글]


donaricano-btn



[최초 등록일: ]
[최종 수정일: 1/18/2021]

Creative Commons License
이 저작물은 크리에이티브 커먼즈 코리아 저작자표시-비영리-변경금지 2.0 대한민국 라이센스에 따라 이용하실 수 있습니다.
by SeongTae Jeong, mailto:techsharer at outlook.com

비밀번호

댓글 쓴 사람
 



2021-01-20 03시02분
[이성환] 아.. 다행이군요. 리눅스에서 httpclient 가 Timeout 대신 TaskCancel 을 계속 발생시켜서 한동안 해멨었는데 이제야 제대로 동작하겠군요.(프로삽실러...;ㅁ; 윈도에서만 디버깅했다가 옴팡 당했던 1인)
[손님]

1  2  3  4  5  6  7  8  9  10  11  12  13  14  [15]  ...
NoWriterDateCnt.TitleFile(s)
12513정성태1/28/20211015오류 유형: 698. electronize - User Profile 디렉터리에 공백 문자가 있는 경우 빌드가 실패하는 문제
12512정성태1/28/20211112오류 유형: 697. The program can't start because VCRUNTIME140.dll is missing from your computer. Try reinstalling the program to fix this problem.
12511정성태1/27/20211099Windows: 187. Windows - 도스 시절의 8.3 경로를 알아내는 방법
12510정성태1/27/20211272.NET Framework: 1020. .NET Core Kestrel 호스팅 - Razor 지원 추가 [1]파일 다운로드1
12509정성태1/27/20211307개발 환경 구성: 524. Jupyter Notebok에서 C#(F#, PowerShell) 언어 사용을 위한 환경 구성
12508정성태1/27/20211050개발 환경 구성: 523. Jupyter Notebook - Slide 플레이 버튼이 없는 경우
12507정성태1/26/2021967VS.NET IDE: 157. Visual Studio - Syntax Visualizer 메뉴가 없는 경우
12506정성태1/25/20211043.NET Framework: 1019. Microsoft.Tye 기본 사용법 소개
12505정성태1/23/20211228.NET Framework: 1018. .NET Core Kestrel 호스팅 - Web API 추가 [1]파일 다운로드1
12504정성태1/23/20211639.NET Framework: 1017. .NET 5에서의 네트워크 라이브러리 개선 (2) - HTTP/2, HTTP/3 관련 [2]
12503정성태1/21/20211405오류 유형: 696. C# - HttpClient: Requesting HTTP version 2.0 with version policy RequestVersionExact while HTTP/2 is not enabled.
12502정성태1/21/20211407.NET Framework: 1016. .NET Core HttpClient의 HTTP/2 지원파일 다운로드1
12501정성태1/21/20211185.NET Framework: 1015. .NET 5부터 HTTP/1.1, 2.0 선택을 위한 HttpVersionPolicy 동작 방식파일 다운로드1
12500정성태1/21/20211260.NET Framework: 1014. ASP.NET Core(Kestrel)의 HTTP/2 지원 여부파일 다운로드1
12499정성태1/20/20211504.NET Framework: 1013. .NET Core Kestrel 호스팅 - 포트 변경, non-localhost 접속 지원 및 https 등의 설정 변경파일 다운로드1
12498정성태1/20/20211253.NET Framework: 1012. .NET Core Kestrel 호스팅 - 비주얼 스튜디오의 Kestrel/IIS Express 프로파일 설정
12497정성태1/20/20211551.NET Framework: 1011. C# - OWIN Web API 예제 프로젝트 [1]파일 다운로드2
12496정성태1/19/20211567.NET Framework: 1010. .NET Core 콘솔 프로젝트에서 Kestrel 호스팅 방법 [1]
12495정성태1/19/20211975웹: 40. IIS의 HTTP/2 지원 여부 - h2, h2c [1]
12494정성태1/19/20211668개발 환경 구성: 522. WSL2 인스턴스와 호스트 측의 Hyper-V에 운영 중인 VM과 네트워크 연결을 하는 방법 [2]
12493정성태1/18/20211382.NET Framework: 1009. .NET 5에서의 네트워크 라이브러리 개선 (1) - HTTP 관련 [1]파일 다운로드1
12492정성태1/17/20211092오류 유형: 695. ASP.NET 0x80131620 Failed to bind to address
12491정성태1/16/20211440.NET Framework: 1008. 배열을 반환하는 C# COM 개체의 메서드를 C++에서 사용 시 메모리 누수 현상 [1]파일 다운로드1
12490정성태1/15/20211688.NET Framework: 1007. C# - foreach에서 열거 변수의 타입을 var로 쓰면 object로 추론하는 문제 [1]파일 다운로드1
12489정성태1/13/20212244.NET Framework: 1006. C# - DB에 저장한 텍스트의 (이모티콘을 비롯해) 유니코드 문자가 '?'로 보인다면?
12488정성태1/13/20211653.NET Framework: 1005. C# - string 타입은 shallow copy일까요? deep copy일까요?파일 다운로드1
1  2  3  4  5  6  7  8  9  10  11  12  13  14  [15]  ...