[OSSCA] #63 엔드포인트 리팩토링 - 2. 오피스아워 Q&A

뚜비·2022년 8월 31일
0

2022 OSSCA

목록 보기
11/14

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



Issue 남기기

코드 분석 이후 내가 궁금한 것들을 issue에 남겨보았다!! 영어로 comment를 남기면서 영어 공부해봐라! 라고 어떤 분이 추천해주셔서 일부러 영어로 남겨보았다.
issue에 'Workflow interface를 만드는게 좋은가'라는 질문을 드렸고
다음과 같은 답변을 해주셨다.



Refactoring Example

지난 8월 18일 8시 오피스아워에서는 멘토님이 질문들을 받고 피드백해주시는 형식으로 진행되었다.


우리가 Refactoring 해야 하는 부분은 다음과 같다.

 public async Task<IActionResult> Run(
            [HttpTrigger(AuthorizationLevel.Function, "GET", Route = "messages/{requestId:regex(^\\d+\\w+$)}")] HttpRequest req,
            string requestId)
        {
            _logger.LogInformation("C# HTTP trigger function processed a request.");

			/*Refactoring start */
            var headers = default(RequestHeaderModel);
            try
            {
                headers = req.To<RequestHeaderModel>(useBasicAuthHeader: true).Validate();
                // headers = await req.To<RequestHeaderModel>(SourceFrom.Header).Validate().ConfigureAwait(false);
            }
            catch (Exception ex)
            {
                return new BadRequestResult();
            }

            var queries = default(GetMessageRequestQueries);
            try 
            {
                queries = await req.To<GetMessageRequestQueries>(SourceFrom.Query).Validate(this._validator).ConfigureAwait(false);
            }
            catch (Exception ex)
            {
                return new BadRequestResult();
            }

            var paths = new GetMessageRequestPaths() { RequestId = requestId };

            var requestUrl = new RequestUrlBuilder()
                .WithSettings(this._settings, this._settings.Endpoints.GetMessage)
                .WithHeaders(headers)
                .WithQueries(queries)
                .WithPaths(paths).Build();

            this._http.DefaultRequestHeaders.Add("X-Secret-Key", headers.SecretKey);
            var result = await this._http.GetAsync(requestUrl).ConfigureAwait(false);

            var payload = await result.Content.ReadAsAsync<GetMessageResponse>().ConfigureAwait(false);
			/*Refactoring end*/
            
            return new OkObjectResult(payload);
        }

그 중에서 멘토님이 headers 부분을 리팩토링하는 것을 예시로 보여주셨다.


Interface 등록

먼저 nt-sms 폴더 안에 Workflows라는 폴더와 HttpTriggerWorkflow.cs 파일을 생성한다. 그 안에 다음과 같은 코드를 적어주셨다.

using System;
using System.Collections.Generic;
using System.Text;


namespace Toast.Sms.Workflows
{
    /// <summary>
    /// This provides interface to the HTTP trigger workflows.
    /// </summary>
    public interface IHttpTriggerWorkflow{

    }

    /// <summary>
    /// This represents the workflow entity for the HTTP trigger.
    /// </summary>
    public class HttpTriggerWorkflow : IHttpTriggerWorkflow{
        
    }
}

코드 분석을 하면서도 알았지만, 위의 코드를 보면서 알게 된 점은 Comment Rule이 존재한다는 것이다. ///, < > </ >를 이용해서 각 메소드에 대한 요약을 해야 한다.


Startup.cs에 가서 Workflow 인터페이스와 객체를 등록해준다.

Startup.cs

Startup.cs는 IoC container로 의존성을 설정하는 곳이다. 위의 코드처럼 의존성을 주입할 때 해당 객체들이 어디서 생성된 건지 정의되지 않았다. 해당 의존성 객체들은 Startup.cs에서 등록된 것으로 Ioc Container에 등록되기만 해도 어디서든 가져다 사용할 수 있다.


Interface & Test 코드 작성

멘토님이 issue에 남기신 sudo code를 바탕으로 우리는 ValidateHeader 메소드를 구현해본다.


using System;
using System.Collections.Generic;
using System.Text;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Http;

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="IHttpTiggeWokflow" /> instance </returns>
        Task<IHttpTriggerWorkflow> ValidateHeaderAsync(HttpRequest req);
        
    }

    /// <summary>
    /// This represents the workflow entity for the HTTP trigger.
    /// </summary>
    public class HttpTriggerWorkflow : IHttpTriggerWorkflow{
        /// <inheritdoc />
        public Task<IHttpTriggerWorkflow> ValidateHeaderAsync(HttpRequest req){
            throw new NotImplementedException();
            /*Logic 채워야 할 부분 */
        }
    }
}

이런식으로 인터페이스 틀을 만들어 둔다! 이때 메소드 구현은 아직 안 한 상태!



Test 폴더에 가서 Workflows > HttpTriggerWorkflowTest.cs를 생성해서 해당 애플리케이션을 test하는 코드를 작성한다.

using System;
using System.Collections.Generic;
using System.Text;
using System.Threading.Tasks;
using FluentAssertions;
using Microsoft.AspNetCore.Http;
using Microsoft.Azure.WebJobs.Extensions.OpenApi.Core.Extensions;
using Microsoft.VisualStudio.TestTools.UnitTesting;
using Moq;
using Toast.Sms.Workflows;

namespace Toast.Sms.Tests.Workflows{
    [TestClass]
    public class HttpTriggerWorkflowTests{

        [TestMethod]
        public void Given_Type_When_Initiated_Then_It_Should_Implement_Interface(){
            // 인터페이스가 맞는지! 제대로 했는지 확인 
            var workflow = new HttpTriggerWorkflow(); // Arrange

            var hasInterface = workflow.GetType().HasInterface<IHttpTriggerWorkflow>();
            
            hasInterface.Should().BeTrue();

        }

        [TestMethod]
        public void Given_HttpRequest_When_Invoke_ValidateHeaderAsync_Then_It_Should_Throw_Exception(){
            // 호출하면 NotImplementedException이 잘 나오는지 
            //Arrange
            var req = new Mock<HttpRequest>(); // HttpRequest를 Mocking했다. 즉, req는 아무것도 없는 가짜 인스턴스라는 뜻!
            var workflow = new HttpTriggerWorkflow();
            
            Action action = () => workflow.ValidateHeaderAsync(req.Object);
            
            action.Should().Throw<NotImplementedException>();
            // 현재 action은 Exception이 나와야 하니까! 

        }

    }
}

각 Test 메소드는 arrange, act, assert 3단계로 이루어진다.


Test 시키면 잘 돌아간다는 것을 볼 수 있다.


Header 메소드 & Test 코드 작성


이제 workflow interface에 해당 헤더 메소드를 구현해보자

public class HttpTriggerWorkflow : IHttpTriggerWorkflow
    {
        private RequestHeaderModel _headers;

        /// <inheritdoc />
        public async Task<IHttpTriggerWorkflow> ValidateHeaderAsync(HttpRequest req)
        {
            var headers = req.To<RequestHeaderModel>(useBasicAuthHeader: true)
                             .Validate();
			// Validate에서 Exception을 throw하면 우리가 구현한 Workflow에 감싼 try catch에서 잡게 된다. 
            this._headers = headers; // 헤더 값은 HttpTriggerWorkflow 인스터스 안의 _headers에 저장된다. 

            return await Task.FromResult(this).ConfigureAwait(false); // 다시 자기 자신을 반환
        }
    }

[TestMethod]
        public void Given_NullHeader_When_Invoke_ValidateHeaderAsync_Then_It_Should_Throw_Exception()
        { // 헤더 자체가 없는 경우 
            var req = new Mock<HttpRequest>();
            var workflow = new HttpTriggerWorkflow();

            Func<Task> func = async () => await workflow.ValidateHeaderAsync(req.Object);
			// ValidateHeaderAsync는 Asyncronous가 되어야 한다.  따라서 async await 키워드 추가! 
            // 또한 return 값이 있으므로 Func<Task> func로 
            
            func.Should().ThrowAsync<RequestHeaderNotValidException>();
            // 가짜 인스턴스를 던져서 헤더는 null값이므로 다음과 같은 Exception이 발생!  
        }

위의 같이 Test를 해보면 Passed가 잘 나온다!


req.To에서 To가 하는 일은 RequestHeader에 Authorization 헤더를 찾아서 위와 같은 연산을 한다. authorization에 Basic으로 replace하고, 이를 Decode를 하면 username : password 이런 식으로 보인다. 그것을 :로 나눠서 AppKey와 SecretKey가 나온다.


[TestMethod]
        public void Given_NoHeader_When_Invoke_ValidateHeaderAsync_Then_It_Should_Throw_Exception()
        {// 헤더에 값이 없는 경우 
        
            // 헤더 설정 
            var headers = new HeaderDictionary();
            headers.Add("Authorization", "Basic");

            var req = new Mock<HttpRequest>();
            req.SetupGet(p => p.Headers).Returns(headers);
			// 가짜 request 인스턴스에 헤더를 설정해주겠다. 
            // 여기서 p는 HttpRequest가 됨
            var workflow = new HttpTriggerWorkflow();

            Func<Task> func = async () => await workflow.ValidateHeaderAsync(req.Object);

            func.Should().ThrowAsync<RequestHeaderNotValidException>();
        }

만약 Appkey나 SecretKey에 값이 존재하지 않으면 또 Exception이 나오는데 이를 Test 하는 코드다


		[DataTestMethod]
        [DataRow("hello", null)] // 데이터 예시 1 - username만 있음, 예외발생
        [DataRow(null, "world")] // 데이터 예시 2 - passwordd만 있음, 예외발생 
        public void Given_InvalidHeader_When_Invoke_ValidateHeaderAsync_Then_It_Should_Throw_Exception(string username, string password)
        {
            var bytes = Encoding.UTF8.GetBytes($"{username}:{password}");
            // username과 password를 Basic Authorization 모양({} : {})대로 byte로 가져와서 
            var encoded = Convert.ToBase64String(bytes);
            // Base64 String으로 인코딩한다. 

			// 헤더 설정 
            var headers = new HeaderDictionary();
            headers.Add("Authorization", $"Basic {encoded}"); // 인코딩한 결과값을 대입 

            var req = new Mock<HttpRequest>();
            req.SetupGet(p => p.Headers).Returns(headers);  

            var workflow = new HttpTriggerWorkflow();

            Func<Task> func = async () => await workflow.ValidateHeaderAsync(req.Object);

            func.Should().ThrowAsync<RequestHeaderNotValidException>();
        }

위의 코드는 유효한 Header인지 검사하는 Test를 작성한 것이다! 이것도 req.To에서 Decode를 하는데 아무것도 없으니 에러가 뜬다



유효한 헤더 Test 코드 작성

[DataTestMethod]
        [DataRow("hello", "world")] // 유효한 데이터 
        public async Task Given_ValidHeader_When_Invoke_ValidateHeaderAsync_Then_It_Should_Return_Result(string username, string password) 
        {	
            var bytes = Encoding.UTF8.GetBytes($"{username}:{password}");
            var encoded = Convert.ToBase64String(bytes);

            var headers = new HeaderDictionary();
            headers.Add("Authorization", $"Basic {encoded}");

            var req = new Mock<HttpRequest>();
            req.SetupGet(p => p.Headers).Returns(headers);

            var workflow = new HttpTriggerWorkflow();

            var result = await workflow.ValidateHeaderAsync(req.Object);

            result.Should().BeOfType<HttpTriggerWorkflow>(); // 모든 인터페이스 메소드는 자기자신을 반환하므로 
        }

더 자세하게 field의 headers에 값이 잘 들어갔는지 확인하고 싶다면

[DataTestMethod]
        [DataRow("hello", "world")]
        public async Task Given_ValidHeader_When_Invoke_ValidateHeaderAsync_Then_It_Should_Contain_Headers(string username, string password)
        {
            var bytes = Encoding.UTF8.GetBytes($"{username}:{password}");
            var encoded = Convert.ToBase64String(bytes);

            var headers = new HeaderDictionary();
            headers.Add("Authorization", $"Basic {encoded}");

            var req = new Mock<HttpRequest>();
            req.SetupGet(p => p.Headers).Returns(headers);

            var workflow = new HttpTriggerWorkflow();

            var result = await workflow.ValidateHeaderAsync(req.Object);
            
            // private field에 값이 잘 들어갔는지 확인! 
            var fi = workflow.GetType().GetField("_headers", BindingFlags.NonPublic | BindingFlags.Instance);
            var field = fi.GetValue(result) as RequestHeaderModel;

            field.Should().NotBeNull(); // 값이 있어! 
            field.AppKey.Should().Be(username);
            field.SecretKey.Should().Be(password);
        }


AddSingleton -> AddScoped

Startup.cs에서 workflow 객체를 Singleton으로 설정했는데 이는 HttpTrigegerWorkflow 인스턴스가 1개만 생성되고 이를 다른 request에서 계속 재사용한다는 의미이다.

어떤 이는 GetMessage, 또 어떤 이는 ListMessage라는 request를 동시에 보낼 수 있는데 HttpTrigger 인스턴스가 이를 다 처리해야 한다. 그러면 field인 _headers 값이 일정하지 않고 계속 변하면서 무결성을 잃게 될 것이다.


따라서 AddScoped로 설정해야 한다. 모든 request에 대해 각 request에서 만큼은 하나의 HttpTrigger 인스턴스가 생성되도록 한다. 즉 모든 Reqeust에 대해 각각 독립적인 하나의 HttpTrigger 인스턴스가 생성된다.

리팩토링 끝!!!


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

0개의 댓글