[OSSCA] #63 엔드포인트 리팩토링 - 3. 리팩토링

뚜비·2022년 9월 6일
0

2022 OSSCA

목록 보기
12/14
post-thumbnail

2022 오픈소스 컨트리뷰션 아카데미 "NHN Toast Power Platform Connector" Masters에 참여하면서 배운 내용을 기록하였습니다.



✅ 주요 회의 내용

8.23

8월 23일에 본격적인 역할 분담 회의를 진행하였고 나는 Build Request Url 부분을 담당하게 되었다.


멘토님께서 다음과 같은 순서로 리팩토링을 해보라고 하셨다!!



8.27

개발 현황 공유 시간



8.31

그 이후 8월 31일날 따로 회의를 진행하였는데 Trigger마다 다른 의존성들을 어떻게 제너릭한 Workflow 객체에서 설정할 수 있는가에 대해 긴 이야기를 나누었다.


이후 자잘자잘한 의사소통이 계속 되었음!!



✅ Refactoring - Query

먼저 우리가 생각한 워크플로우는 다음과 같다.

  1. Pass each Endpoints Query class(ex. GetMessageQueries) as Generic Type
    (각 엔트포인트 쿼리 클래스(ex. GetMessageQueries)를 Generic Type로서 전달)
  2. Pass IValidator as parameters
    (IValidator는 매개변수로 전달 )
  3. Call Validate(val) method.
    (Validate(val) 메소드를 호출)

이때, Workflow 객체에서 의존성들을 사용해야 하는 경우가 있는데 Trigger 객체에 존재하는 의존성들을 가져와야 하기 때문에 Generic Type을 이용하여 파라미터로 전달받은 것!


public interface IHttpTriggerWorkflow{
        /// <summary>
        /// Validates the request header. 
        /// </summary>
        /// <returns>Returns the <see cref="IHttpTriggerWorkflow"/> instance.</returns>
        Task<IHttpTriggerWorkflow> ValidateQueryAsync<T>(HttpRequest req, IValidator<T> validator) where T : BaseRequestQueries;
}

public class HttpTriggerWorkflow : IHttpTriggerWorkflow
{
        public async Task<IHttpTriggerWorkflow> ValidateQueryAsync<T>(HttpRequest req, IValidator<T> val) where T : BaseRequestQueries
        { 
            var queries = await req.To<T>(SourceFrom.Query).Validate(val).ConfigureAwait(false);

            this._queries = queries;

            return await Task.FromResult(this).ConfigureAwait(false);
       }
}

먼저 인터페이스에 메소드를 위와 같이 작성해주고 다음과 같이 메소드를 구현했다.


❌문제발생❌ - c# CS7036

위와 같이 CS7036 에러가 발생했다!! 자세한 문제는 이슈에 남긴 질문 Issue Link를 참고바란다.



해결방법

IValidator<GetMessageRequestQueries>로 바꿨는데 이번에는 다음과 같은 에러가 떴고


using Toast.Sms.Valiator 추가하니까 사라졌다...

더 자세한 문제 해결 방법은 다음 Issue Link를 참고하길 바란다!



❌문제발생❌ - c# CS0029

queries는 System.Runtime.CompilerServices.ConfigureTaskAwaitable<T> 타입인데 this._queries는 BaseRequestQueries라 타입 변환 에러가 뜬 것!


해결방법

Stack Overflow를 확인한 결과 queries.GetAwaiter().GetResult()를 통해 해결하면 된다고 판단하고 해결된 줄 알고 디스코드에 올렸는데.....!

어떻게 해결했는지는 그것이 알고싶다 글을 참고하길 바란다!



✅ Refactoring - Build Request

모든 EndPoints들이 path를 받지 않는다.

내가 Refactoring 해야 하는 부분이다. 이때 주의해야 할 점은 paths 변수인데 Endpoint에 따라 path를 받기도 하고 받지 않기도 해서 이를 어떻게 처리해야 하나 고민을 했는데


이와 같이 2개의 경우로 메소드를 나누었다...(바보같음 주의) 또 endpoint 값도 Endpoint들의 이름만 필요하겠지? 하고 저런 코드를 작성했다..


코드가 중복되는 것 같아 이를 해결할 수 있지 않을까 해서 해당 코드를 줄일 수 있는지, 또 path가 필요한 클래스인데 path=null인 경우 예외를 잡을 수 있는지 질문을 드렸고..


path가 있는 경우와 없는 경우에 대해서 이미 WithPath, Build 메소드에서 알아서 처리하기 때무네.. 내가 상관할 필요가 없었다!! 따라서 path 클래스는 파라미터로 null로 초기화시키고 전달받는 식으로 처리했다!



Endpoint 값을 settings -> Endpoint -> SmsEndpointSettings의 필드로 얻어야 한다고?!

 public async Task<IHttpTriggerWorkflow> BuildRequestUrl<Tresult>(ToastSettings<SmsEndpointSettings> settings, BaseRequestPaths paths = null) {
            // var paths = new GetMessageRequestPaths() { RequestId = requestId };
            string name = nameof(Tresult); 
            PropertyInfo endpoint = settings.Endpoints.GetType().GetProperty(name, BindingFlags.Public | BindingFlags.Instance); // 이 부분이 null레퍼예외 발생
 
            var requestUrl = new RequestUrlBuilder()
                .WithSettings(settings, endpoint.GetValue(settings.Endpoints).ToString())
                .WithHeaders(_headers) 
                .WithQueries(_queries)
                .WithPaths(paths).Build();

            this._requestUrl = requestUrl;
 
            return await Task.FromResult(this).ConfigureAwait(false);   
        }

_endpoint는 settings의 Endpoints 필드의 SmsEndpointSettings 클래스의 필드 변수를 받기 때문에 (그 변수 이름들은 GetMessage와 같은 엔드포인트 클래스 이름이고 나중에 url Build할 때 해당 endpoint 변수를 사용한다) 위와 같이 c#의 reflection으로 동적 호출 하는 코드를 작성했다. Reflection 참고!

❌문제발생❌ - NullReferenceException

[TestMethod]
        // 잘못된 이름의 endpoint일 때 
        public async Task Given_InvalidEndpoint_When_Invoke_BuildRequestUrl_Then_It_Should_Throw_ExceptionAsync()
        {
            var settings = new ToastSettings<SmsEndpointSettings>();
            var workflow = new HttpTriggerWorkflow();

            var result = await workflow.BuildRequestUrl<GetMessage>(settings);
            // var f = fi.GetValue(result);
            // f.Should().BeNull();
            
            //func.Should().ThrowAsync<ArgumentException>();
        }

다음과 같은 테스트 코드를 작성하면
자꾸 이런 문제가 떴다..


그래서 이런 스레드를 남겼다.
이에 멘토님이 샘플 코드를 보여주겠다 하셨고.. 아.. 그냥 엔드포인트도 파라미터로 받으면 해결되는 문제였다.. 걍 코드 자체ㅏ 문제였음 그리고 nullReference 문제는 가져오는 방식에서 내가 잘 못한 문제가 있었는 듯!



git add .는 하면 안 된다

일단 pr을 보내고 멘토님이 comment를 남겨주셧다.


이것은 나의 mistake... 선배들이 git add . 하지 말라고 했는데..

그냥 코드 잘 치면 상관없겠지?! 했다가... 네... 안 된다는 것을 뼈저리게 느꼈다. 헝헝헝...

깃 크라켄 잘 활용하도록 하자


그래서 최종 HttpWorkflow.cs는?

namespace Toast.Sms.Workflows
{
    /// <summary>
    /// This provides interface to the HTTP trigger workflows.
    /// </summary>
    public interface IHttpTriggerWorkflow
    {
        /// <summary>
        /// Validates the request header.
        /// </summary>
        /// <param name="req"><see cref="HttpRequest"/> instance.</param>
        /// <returns>Returns the <see cref="IHttpTriggerWorkflow"/> instance.</returns>
        Task<IHttpTriggerWorkflow> ValidateHeaderAsync(HttpRequest req);

        /// <summary>
        /// Validates the request queries.
        /// </summary>
        /// <typeparam name="T">Type of the request query object.</typeparam>
        /// <param name="req"><see cref="HttpRequest"/> instance.</param>
        /// <param name="validator"><see cref="IValidator{T}"/> instance.</param>
        /// <returns>Returns the <see cref="IHttpTriggerWorkflow"/> instance.</returns>
        Task<IHttpTriggerWorkflow> ValidateQueriesAsync<T>(HttpRequest req, IValidator<T> validator) where T : BaseRequestQueries;

        /// <summary>
        /// Builds the request URL with given path parameters.
        /// </summary>
        /// <param name="endpoint">API endpoint.</param>
        /// <param name="settings"><see cref="ToastSettings"/> instance.</param>
        /// <param name="paths">Instance inheriting <see cref="BaseRequestPaths"/> class.</param>
        /// <returns>Returns the <see cref="IHttpTriggerWorkflow"/> instance.</returns>
        Task<IHttpTriggerWorkflow> BuildRequestUrlAsync(string endpoint, ToastSettings settings, BaseRequestPaths paths = null);


        /// <summary>
        /// Invokes the API request.
        /// </summary>
        /// <typeparam name="T">Type of response model.</typeparam>
        /// <param name="method"><see cref="HttpMethod"/> value.</param>
        /// <returns>Returns the instance inheriting <see cref="ResponseModel"/> class.</returns>
        Task<T> InvokeAsync<T>(HttpMethod method) where T : ResponseModel;
    }

    /// <summary>
    /// This represents the workflow entity for the HTTP triggers.
    /// </summary>
    public class HttpTriggerWorkflow : IHttpTriggerWorkflow
    {
        private readonly HttpClient _http;

        private RequestHeaderModel _headers;
        private BaseRequestQueries _queries;
        private BaseRequestPayload _payload;
        private string _requestUrl;
        private readonly MediaTypeFormatter _formatter;


        /// <summary>
        /// Initializes a new instance of the <see cref="HttpTriggerWorkflow"/> class.
        /// </summary>
        /// <param name="factory"><see cref="IHttpClientFactory"/> instance.</param>
        public HttpTriggerWorkflow(IHttpClientFactory factory, MediaTypeFormatter formatter)
        {
            this._http = factory.ThrowIfNullOrDefault().CreateClient("messages");
            this._formatter = formatter.ThrowIfNullOrDefault();
        }

        /// <inheritdoc />
        public async Task<IHttpTriggerWorkflow> ValidateHeaderAsync(HttpRequest req)
        {
            var headers = req.To<RequestHeaderModel>(useBasicAuthHeader: true)
                             .Validate();

            this._headers = headers;

            return await Task.FromResult(this).ConfigureAwait(false);
        }

        /// <inheritdoc />
        public async Task<IHttpTriggerWorkflow> ValidateQueriesAsync<T>(HttpRequest req, IValidator<T> validator) where T : BaseRequestQueries
        {
            var queries = await req.To<T>(SourceFrom.Query)
                                   .Validate(validator)
                                   .ConfigureAwait(false);

            this._queries = queries;

            return await Task.FromResult(this).ConfigureAwait(false);
        }

        /// <inheritdoc />
        public async Task<IHttpTriggerWorkflow> BuildRequestUrlAsync<T>(string endpoint, ToastSettings settings, T paths = null)
        {

            var builder = new RequestUrlBuilder()
                             .WithSettings(settings, endpoint)
                             .WithHeaders(this._headers)
                             .WithQueries(this._queries);
        
            if (!paths.IsNullOrDefault())
            {
                builder = builder.WithPaths(paths);
            }

            var requestUrl = builder.Build();

            this._requestUrl = requestUrl;

            return await Task.FromResult(this).ConfigureAwait(false);
        }

        /// <inheritdoc />
        public async Task<T> InvokeAsync<T>(HttpMethod method) where T : ResponseModel
        {
            var request = new HttpRequestMessage(method, this._requestUrl);
            if (!this._payload.IsNullOrDefault())
            {
                request.Content = new ObjectContent(
                    this._payload.GetType(), this._payload, this._formatter);
            }

            this._http.DefaultRequestHeaders.Add("X-Secret-Key", this._headers.SecretKey);
            var result = await this._http.SendAsync(request).ConfigureAwait(false);

            var payload = await result.Content.ReadAsAsync<T>().ConfigureAwait(false);

            return await Task.FromResult(payload).ConfigureAwait(false);
        }
    }

    /// <summary>
    /// This represents the extension class for <see cref="IHttpTriggerWorkflow"/>.
    /// </summary>
    public static class HttpTriggerWorkflowExtensions
    {
        /// <summary>
        /// Validates the request queries.
        /// </summary>
        /// <typeparam name="T">Type of the request query object.</typeparam>
        /// <param name="workflow"><see cref="IHttpTriggerWorkflow"/> instance wrapped with <see cref="Task"/>.</param>
        /// <param name="req"><see cref="HttpRequest"/> instance.</param>
        /// <param name="validator"><see cref="IValidator{T}"/> instance.</param>
        /// <returns>Returns the <see cref="IHttpTriggerWorkflow"/> instance.</returns>
        public static async Task<IHttpTriggerWorkflow> ValidateQueriesAsync<T>(this Task<IHttpTriggerWorkflow> workflow, HttpRequest req, IValidator<T> validator) where T : BaseRequestQueries
        {
            var instance = await workflow.ConfigureAwait(false);

            return await instance.ValidateQueriesAsync(req, validator);
        }

        /// <summary>
        /// Builds the request URL with given path parameters.
        /// </summary>
        /// <typeparam name="T">Type of request path object.</typeparam>
        /// <param name="workflow"><see cref="IHttpTriggerWorkflow"/> instance wrapped with <see cref="Task"/>.</param>
        /// <param name="endpoint">API endpoint.</param>
        /// <param name="settings"><see cref="ToastSettings"/> instance.</param>
        /// <param name="paths">Instance inheriting <see cref="BaseRequestPaths"/> class.</param>
        /// <returns>Returns the <see cref="IHttpTriggerWorkflow"/> instance.</returns>
        public static async Task<IHttpTriggerWorkflow> BuildRequestUrlAsync(this Task<IHttpTriggerWorkflow> workflow, string endpoint, ToastSettings settings, BaseRequestPaths paths = null)
        {
            var instance = await workflow.ConfigureAwait(false);

            return await instance.BuildRequestUrlAsync(endpoint, settings, paths);
        }

        /// <summary>
        /// Invokes the API request.
        /// </summary>
        /// <typeparam name="T">Type of response model.</typeparam>
        /// <param name="workflow"><see cref="IHttpTriggerWorkflow"/> instance wrapped with <see cref="Task"/>.</param>
        /// <param name="method"><see cref="HttpMethod"/> value.</param>
        public static async Task<T> InvokeAsync<T>(this Task<IHttpTriggerWorkflow> workflow, HttpMethod method) where T : ResponseModel
        {
            var instance = await workflow.ConfigureAwait(false);

            return await instance.InvokeAsync<T>(method);
        }
    }
}


BuildRequestAsync type 수정을 해보았다.

/// <inheritdoc />
        public async Task<IHttpTriggerWorkflow> BuildRequestUrlAsync(string endpoint, ToastSettings settings, BaseRequestPaths paths = null)
        {
            var builder = new RequestUrlBuilder()
                             .WithSettings(settings, endpoint)
                             .WithHeaders(this._headers)
                             .WithQueries(this._queries);
        
            if (!paths.IsNullOrDefault())
            {
                builder = builder.WithPaths(paths);
            }

            var requestUrl = builder.Build();

            this._requestUrl = requestUrl;

            return await Task.FromResult(this).ConfigureAwait(false);
        }

코드를 보다가 굳이 BaseRequestUrlAsync 부분은 Generic Type으로 받을 필요가 없지 않을까 생각했다. 그래서 BaseRequestPaths paths로 수정하고 pr을 보냈다! Add BuildRequest Test


그러나 내가 코드를 수정한 근거를 다른 팀원에게 자세하게 답변하지 않아 소통 이슈가 발생하였다..

이렇게 설명을 했더니 멘토님께서 그렇게 결정한 부분을 단위테스트로 검증해보라고 말씀해주셨다!! 즉 GetMessage와 같은 endpoint에서 BuildRequestUrlAsync 메소드를 호출할 때GetMessageRequestPaths의 Type이 잘 넘어가는지 확인 필요하다는 것!


사실 멘토님이 단위테스트로 검증해보라고 말씀해주시기 전 스스로 검증해보았댜. 검증 과정은 다음과 같댜~~

public async Task<IHttpTriggerWorkflow> BuildRequestUrlAsync(string endpoint, ToastSettings settings, BaseRequestPaths paths = null)
        {
            // var builder = new RequestUrlBuilder()
            //                  .WithSettings(settings, endpoint)
            //                  .WithHeaders(this._headers)
            //                  .WithQueries(this._queries);
            var builder = new RequestUrlBuilder();

            if (!paths.IsNullOrDefault())
            {
                builder = builder.WithPaths(paths);
            }

            this._build = builder;

            // var requestUrl = builder.Build();

            // this._requestUrl = requestUrl;

            return await Task.FromResult(this).ConfigureAwait(false);
        }

해당 BuildRequestUrlAync 메소드에서 RequestUrlBuilder 클래스를 전달받은 후


 public RequestUrlBuilder WithPaths<T>(T paths) where T : BaseRequestPaths
        {
            this.test_paths = paths; // path 클래스 받기 
            // if (this._settings == null)
            // {
            //     throw new InvalidOperationException("Invalid ToastSettings.");
            // }

            // var serialised = JsonConvert.SerializeObject(paths, this._settings.JsonFormatter.SerializerSettings);
            // var deserialised = JsonConvert.DeserializeObject<Dictionary<string, string>>(serialised);

            // this._paths = deserialised;

            return this;
        }

RequestUrlBuilder 클래스에서 WithPath 메소드에서 path 클래스를 받아왔습니다!


다음과 같이 Test Code를 작성해보았는데요!

[TestMethod]
        public void Given_ValidPath_When_Invoke_RequestUrlBuilder_Then_It_Should_Return_GetMessagePath2()
        {
            var path = new GetMessageRequestPaths() {RequestId = "test"};
            var workflow = new HttpTriggerWorkflow(this._factory.Object, this._fomatter.Object);

            // buildRequest 호출 후 RequestUrlBuilder 객체 받기 
            var build = workflow.BuildRequestUrlAsync("test", new ToastSettings<SmsEndpointSettings>(), path);
            var fi = workflow.GetType().GetField("_build", BindingFlags.NonPublic | BindingFlags.Instance);
            var field = fi.GetValue(workflow);

            // RequestUrlBuiler에서 해당 path 클래스 type 조사 
            var f = field.GetType().GetField("test_paths", BindingFlags.NonPublic | BindingFlags.Instance);
            f.GetValue(field).Should().BeOfType<BaseRequestPaths>();
        }

GetMessageRequestPath 클래스가 나온다는 것을 확인할 수 있었습니다!


그러고 나서야 멘토님의 트리플 따봉을 받을 수 있었다.

지금 생각해보면 별거 아닌데 저때는 세계 수학 7대 난제 증명한 것처럼 뿌듯했음



✅ 이제 우리가 해야 할 TEST 코드 작성..

먼저 멘토님이 Invoke 테스트 코드를 작성해주신 것을 분석해보았다.

[DataTestMethod]
        [DataRow(HttpVerbs.POST, HttpStatusCode.OK, true, 200, "hello world", "lorem ipsum")]
        public async Task Given_Payload_When_Invoke_InvokeAsync_Then_It_Should_Return_Result(string method, HttpStatusCode statusCode, bool isSuccessful, int resultCode, string resultMessage, string body)
        {
        	/*FakeResponse에 대한 Header와 Body 직접 값 대입 후 생성*/
            var model = new FakeResponseModel() // 빈 Model을 생성 
            {
                Header = new ResponseHeaderModel()
                {
                    IsSuccessful = isSuccessful, // true
                    ResultCode = resultCode, // 200
                    ResultMessage = resultMessage "Hello Word"
                },
                Body = body // string 값 body
            };
            
            // 직렬화 해줌. 이떄 JsonMediaTypeFormatter() 객체에 의존성을 가짐
            var content = new ObjectContent<FakeResponseModel>(model, new JsonMediaTypeFormatter(), MediaTypeNames.Application.Json);
            
            var options = new HttpMessageOptions()
            {
                HttpResponseMessage = new HttpResponseMessage(statusCode) { Content = content }
            };
			
            // FakeHttpMessage를 처리할 Handler 객체 
            var handler = new FakeHttpMessageHandler(options);

			/*서버에 보낼 Client 객체 생성*/
            var http = new HttpClient(handler);
            this._factory.Setup(p => p.CreateClient(It.IsAny<string>())).Returns(http);

			/* Request Model 생성 */
            var workflow = new HttpTriggerWorkflow(this._factory.Object);

            var header = new RequestHeaderModel() { AppKey = "hello", SecretKey = "world" };
            var headers = typeof(HttpTriggerWorkflow).GetField("_headers", BindingFlags.Instance | BindingFlags.NonPublic);
            headers.SetValue(workflow, header);

            var url = "http://localhost:7071/api/HttpTrigger";
            var requestUrl = typeof(HttpTriggerWorkflow).GetField("_requestUrl", BindingFlags.Instance | BindingFlags.NonPublic);
            requestUrl.SetValue(workflow, url); // 해당 필드를 담는 객체와 그 필드에 들어갈 값

            var load = new FakeRequestModel()
            {
                FakeProperty1 = "lorem ipsum"
            };
            var payload = typeof(HttpTriggerWorkflow).GetField("_payload", BindingFlags.Instance | BindingFlags.NonPublic);
            payload.SetValue(workflow, load);

			/*Invoke method에 대해 test, 이때 가짜 리스폰스를 결과값으로 받는다. */
            var result = await workflow.InvokeAsync<FakeResponseModel>(new HttpMethod(method)).ConfigureAwait(false);

            result.Header.IsSuccessful.Should().Be(isSuccessful);
            result.Header.ResultCode.Should().Be(resultCode);
            result.Header.ResultMessage.Should().Be(resultMessage);
            result.Body.Should().Be(body);
        }
  • FakeesponseModel : Tests.Common.Fakes에서 Body 변수(여기서는 string type)와 Header 변수(ResponseHeaderModel type)를 가지는 객체
  • ObjectContent : FakeModel 객체를 직렬화 해주기 위한 객체
  • FakeHttpMessagehandler : Fake 객체에 대해 HttpMessage가 들어오는 경우 처리해주는 핸들러이다
  • HttpMessageOptions() : RequestUri, HttpMethod, HttpResponseMessage, HttpContent, Headers, NumberOfTimesCalled 등의 변수들을 담는 객체

위의 테스트 코드는 FakeResponse에 값을 먼저 초기화한 후 header와 query를 직접 넣은 뒤에 InvokeAsync를 호출한다. 이때 Nhn Cloud API를 호출하게 되는데 (ReadAsync) 실제 클라우드에서는 비정상 처리하지만 테스트에서는 해당 Response에 값이 잘 들어있는지 확인하기 위한 용도로 사용한다.



To be Continued...

profile
SW Engineer 꿈나무 / 자의식이 있는 컴퓨터

0개의 댓글