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)
12615정성태4/26/2021358오류 유형: 712. Microsoft Live 로그인 - 계정을 선택하는(Pick an account) 화면에서 진행이 안 되는 문제
12614정성태4/24/2021704개발 환경 구성: 570. C# - Azure AD 인증을 지원하는 ASP.NET Core/5+ 웹 애플리케이션 예제 구성 [3]파일 다운로드1
12613정성태4/23/2021465.NET Framework: 1048. C# - ETW 이벤트의 Keywords에 속한 EventId 구하는 방법 (2) 관리 코드파일 다운로드1
12612정성태4/23/2021513.NET Framework: 1047. C# - ETW 이벤트의 Keywords에 속한 EventId 구하는 방법 (1) PInvoke파일 다운로드1
12611정성태4/22/2021473오류 유형: 711. 닷넷 EXE 실행 오류 - Mixed mode assembly is build against version 'v2.0.50727' of the runtime
12610정성태4/22/2021569.NET Framework: 1046. C# - 컴파일 시점에 참조할 수 없는 타입을 포함한 이벤트 핸들러를 Reflection을 이용해 구독하는 방법파일 다운로드1
12609정성태4/22/2021633.NET Framework: 1045. C# - 런타임 시점에 이벤트 핸들러를 만들어 Reflection을 이용해 구독하는 방법파일 다운로드1
12608정성태4/21/2021893.NET Framework: 1044. C# - Generic Host를 이용해 .NET 5로 리눅스 daemon 프로그램 만드는 방법 [7]파일 다운로드1
12607정성태4/21/2021607.NET Framework: 1043. C# - 실행 시점에 동적으로 Delegate 타입을 만드는 방법파일 다운로드1
12606정성태4/21/2021667.NET Framework: 1042. C# - enum 값을 int로 암시적(implicit) 형변환하는 방법? [2]파일 다운로드1
12605정성태4/18/2021541.NET Framework: 1041. C# - AssemblyID, ModuleID를 관리 코드에서 구하는 방법파일 다운로드1
12604정성태4/18/2021469VS.NET IDE: 163. 비주얼 스튜디오 속성 창의 "Build(빌드)" / "Configuration(구성)"에서의 "활성" 의미
12603정성태4/16/2021582VS.NET IDE: 162. 비주얼 스튜디오 - 상속받은 컨트롤이 디자인 창에서 지원되지 않는 문제
12602정성태4/16/2021732VS.NET IDE: 161. x64 DLL 프로젝트의 컨트롤이 Visual Studio의 Designer에서 보이지 않는 문제
12601정성태4/15/2021533.NET Framework: 1040. C# - REST API 대신 github 클라이언트 라이브러리를 통해 프로그래밍으로 접근
12600정성태4/15/2021609.NET Framework: 1039. C# - Kubeconfig의 token 설정 및 인증서 구성을 자동화하는 프로그램
12599정성태4/14/2021487.NET Framework: 1038. C# - 인증서 및 키 파일로부터 pfx/p12 파일을 생성하는 방법파일 다운로드1
12598정성태4/14/2021540.NET Framework: 1037. openssl의 PEM 개인키 파일을 .NET RSACryptoServiceProvider에서 사용하는 방법 (2)파일 다운로드1
12597정성태4/13/2021458개발 환경 구성: 569. csproj의 내용을 공통 설정할 수 있는 Directory.Build.targets / Directory.Build.props 파일
12596정성태4/12/2021577개발 환경 구성: 568. Windows의 80 포트 점유를 해제하는 방법
12595정성태4/12/2021474.NET Framework: 1036. SQL 서버 - varbinary 타입에 대한 문자열의 CAST, CONVERT 변환을 C# 코드로 구현
12594정성태4/11/2021610.NET Framework: 1035. C# - kubectl 명령어 또는 REST API 대신 Kubernetes 클라이언트 라이브러리를 통해 프로그래밍으로 접근 [1]파일 다운로드1
12593정성태4/10/2021571개발 환경 구성: 567. Docker Desktop for Windows - kubectl proxy 없이 k8s 대시보드 접근 방법
12592정성태4/10/2021494개발 환경 구성: 566. Docker Desktop for Windows - k8s dashboard의 Kubeconfig 로그인 및 Skip 방법
12591정성태4/9/2021879.NET Framework: 1034. C# - byte 배열을 Hex(16진수) 문자열로 고속 변환하는 방법파일 다운로드1
12590정성태4/9/2021524.NET Framework: 1033. C# - .NET 4.0 이하에서 Console.IsInputRedirected 구현
1  2  3  4  5  6  7  8  9  [10]  11  12  13  14  15  ...