본문 바로가기

BackEnd

함수 파이프라인 합성하기 with F# 1. 검증 로직 구현하기

반응형

https://pragprog.com/titles/swdddf/domain-modeling-made-functional/

 

Domain Modeling Made Functional

Use domain-driven design to effectively model your business domain, and implement that model with F#.

pragprog.com

이펙트 시그니처를 피해가며 파이프라인의 단계를 구현하고, 종속성을 주입하는 방법을 알아봅니다.

또한 세 가지 함수형 프로그래밍 패턴을 알아봅니다.

  • "adapter function"를 사용하여 함수를 한 "모양"에서 다른 "모양"으로 변환.
    • (ex : checkProductCodeExists의 출력을 bool에서 ProductCode로 변경)
  • 다른 타입을 공통 타입으로 "lifting"
  • Partial Application을 사용하여 종속성을 함수 안에 "bake in"
    • 함수를 보다 쉽게 ​​합성할 수 있고 호출자로부터 불필요한 구현 세부 정보를 숨길 수 있습니다.

Implementation: Composing a Pipeline

지금까지 타입만 사용하여 도메인을 모델링하는 데 많은 시간을 보냈습니다. 이제 구현해봅시다.
설계를 요약하자면 워크플로는 파이프라인의 각 단계가 작은 파이프로 설계된 일련의 문서 변환(파이프라인)으로 생각할 수 있습니다.

https://itchallenger.tistory.com/423

 

파이프라인으로 워크플로 모델링하기 - 워크플로 합성 및 나머지

서브스텝을 조합하여 워크플로 만들기 이제 모든 단계에 대한 정의가 있습니다. 따라서 각각에 대한 구현이 있을 때 한 단계의 출력을 다음 단계의 입력에 연결하여 전체 워크플로를 구축할 수

itchallenger.tistory.com

기술적인 관점에서 파이프라인에는 다음 단계가 있습니다.

 
  • UnvalidatedOrder로 시작하여 ValidatedOrder로 변환하고 유효성 검사가 실패하면 오류를 반환합니다.
  • 유효성 검사 단계의 출력(ValidatedOrder)을 가져와서 몇 가지 추가 정보를 추가하여 PricedOrder로 바꿉니다.
  • 가격 책정 단계의 결과를 가져와서 주문 승인 메일을 송신합니다.
  • 발생한 일을 나타내는 이벤트 집합을 만들고 반환합니다.
기술적인 세부 사항에 얽매이지 않고 원래 요구 사항을 유지하면서 이것을 코드로 바꾸고 싶습니다.
다음은 각 단계의 함수를 연결하기 위해 파이핑 접근 방식을 사용하여 코드를 표시하는 방법의 예입니다.
​ 	​let​ placeOrder unvalidatedOrder =
​ 	  unvalidatedOrder
​ 	  |> validateOrder
​ 	  |> priceOrder
​ 	  |> acknowledgeOrder
​ 	  |> createEvents
일련의 단계를 조합한 이 코드는 개발자가 아니더라도 이해하기 쉽습니다.
이 워크플로를 구현하는 데는
개별 단계를 만든 다음
함께 결합하는
두 파트가 있습니다.
먼저 파이프라인의 각 단계를 독립적인 함수로 구현하여 상태가 없고 부작용이 없는지 확인하고, 독립적으로 테스트하고 추론할 수 있습니다.
다음으로, 우리는 이러한 작은 함수를 하나의 큰 함수로 구성합니다.
간단하지는 않습니다. 함수들은 때로 서로 잘 맞지 않습니다. 즉, 하나의 출력이 다음 입력과 일치하지 않습니다.
이를 극복하려면 각 단계의 입력과 출력을 조작하여 합성할 수 있도록 하는 방법을 배워야 합니다.
 
 
두 가지 이유로 함수를 합성 할 수 없습니다.
  • 종속성 :
    • 일부 함수에는 데이터 파이프라인의 일부가 아니지만 구현에 필요한 추가 매개변수가 있습니다. 이러한 매개변수를 "종속성"이라고 합니다.
  • 이펙트 :
    • 함수 시그니처에서 Result와 같은 래퍼 유형을 사용하여 오류 처리와 같은 "효과"를 명시적으로 표시했습니다. 그러나 이는 출력에 이펙트가 있는 함수가 래핑되지 않은 primitive 데이터를 입력으로 갖는 함수에 직접 연결할 수 없음을 의미합니다.
이번 주제에서는 종속성 입력으로 작업하는 첫 번째 문제를 다루고 "종속성 주입"과 같은 기능을 수행하는 방법을 볼 것입니다.
이펙트를 사용하는 방법은 다음에 알아봅니다.
즉, 첫 번째 단계에서는 Result 및 Async와 같은 효과에 대해 걱정하지 않고 모든 단계를 구현합니다.
이를 통해 합성의 기본에 집중할 수 있습니다.


Simple Type 다루기 ((단일) 초이스 타입)

워크플로 자체의 단계를 구현하기 전에 먼저 OrderId 및 ProductCode와 같은 "simple types"을 구현해야 합니다.
대부분의 타입은 어떤 식으로든 제약을 받기 때문에 앞에서 논의한 제약 타입 구현에 대한 개요를 따를 것입니다.
각 단순 타입에 대해 최소한 두 개의 헬퍼 함수가 필요합니다.
  • string 또는 int와 같은 primitive에서 타입을 생성하는 create 함수
    • 예를 들어 OrderId.create는 문자열에서 OrderId를 생성하거나 문자열이 잘못된 형식인 경우 오류를 발생시킵니다.
  • Wrapper 타입 내부의 primitive 값을 추출하는 값 함수
일반적으로 이러한 헬퍼 함수는 단순 타입과 동일한 이름을 가진 모듈을 사용하여 단순 타입과 동일한 파일에 넣습니다.

 

예제 : 다음은 도메인 모듈의 OrderId에 대한 정의와 헬퍼 함수입니다.
    //>SimpleTypes1
    module Domain = 
        type OrderId = private OrderId of string

        module OrderId =
            /// Define a "Smart constructor" for OrderId 
            /// string -> OrderId 
            let create str = 
                if String.IsNullOrEmpty(str) then
                    // use exceptions rather than Result for now
                    failwith "OrderId must not be null or empty" 
                elif str.Length > 50 then
                    failwith "OrderId must not be more than 50 chars" 
                else
                    OrderId str

            /// Extract the inner value from an OrderId
            /// OrderId -> string
            let value (OrderId str) = // unwrap in the parameter!
              str                     // return the inner value
    //<
  • create 함수는 지금은 효과를 피하고 있기 때문에 Result type을 반환하는 대신 오류에 대해 예외(failwith)를 사용한다는 점을 제외하면 설계와 유사합니다.
  • value 함수는 매개변수를 이용해 한 단계 패턴 매치를 수행 후 추출합니다.

함수 타입을 구현 지침으로 사용하기

모델링 장에서 워크플로의 각 단계를 나타내는 특수 함수 타입을 정의했습니다.

이제 구현할 시간입니다. 코드가 이를 준수하도록 하려면 어떻게 해야 할까요?

 

가장 간단한 접근 방식은 정상적인 방법으로 함수를 정의하고 나중에 사용할 때 잘못되면 타입 검사 오류가 발생할 것이라고 신뢰하는 것입니다.
예를 들어 이전에 디자인한 ValidateOrder 타입에 대한 참조 없이 아래와 같이 validateOrder 함수를 정의할 수 있습니다.
​ 	​let​ validateOrder
​ 	  checkProductCodeExists ​// dependency​
​ 	  checkAddressExists     ​// dependency​
​ 	  unvalidatedOrder =     ​// input​
​ 	    ...
이것은 대부분의 F# 코드에 대한 표준 접근 방식이지만 특정 함수 타입을 구현하고 있음을 명확히 하려는 경우 다른 스타일을 사용할 수 있습니다.
함수 타입을 애너테이션으로 활용하여 함수를 작성할 수 있고 함수 본문은 람다로 작성할 수 있습니다. 

(주 : DotDotDot은 IDE 빨간줄을 피하기위한 PlaceHolder로 ...를 의미합니다. 지금은 디테일이 중요한게 아니니까요.)

    type Param1 = DotDotDot
    type Param2 = DotDotDot
    type Result = DotDotDot

    (*
    "if we want to make it clear that we are implementing a specific function signature"
    *)
    //>UsingFunctionSignatures2
    // define a function signature
    type MyFunctionSignature = Param1 -> Param2 -> Result

    // define a function that implements that signature
    let myFunc: MyFunctionSignature = 
        fun param1 param2 -> 
            dotDotDot()
    //<​
이 접근 방식을 validateOrder 함수에 적용해봅시다.
    type ValidateOrder = 
        CheckProductCodeExists    // dependency
          -> CheckAddressExists   // dependency
          -> UnvalidatedOrder     // input
          -> Result<ValidatedOrder,ValidationError list>  // output
          
          
          
      (*
    "Applying this approach to the `validateOrder` function gives us:"
    *)
    //>UsingFunctionSignatures3
    let validateOrder : ValidateOrder = 
      fun checkProductCodeExists checkAddressExists unvalidatedOrder -> 
        // ^dependency           ^dependency        ^input   
            dotDotDot()
    //<
이것의 좋은 점은 모든 매개변수와 반환 값이 함수 타입에 따라 결정되는 타입을 가지므로 구현에서 실수를 하면 함수 정의 내부에서 로컬로 오류가 발생한다는 것입니다. 함수를 조합할 때도 마찬가지입니다.
 
실수로 checkProductCodeExists 함수에 정수를 전달하면 타입 체킹 시 오류가 발생합니다.
​ 	​let​ validateOrder : ValidateOrder =
​ 	  ​fun​ checkProductCodeExists checkAddressExists unvalidatedOrder ->
​ 	    ​if​ checkProductCodeExists 42 ​then​
​ 	      ​//       compiler error ^​
​ 	      ​// This expression was expected to have type ProductCode​
​ 	      ​// but here has type int​
​ 	      ...
​ 	    ...
 
 
매개변수의 타입을 결정하기 위한 함수 타입이 없으면 컴파일러는 타입 추론를 사용하며 checkProductCodeExists가 int를 파라미터로 동작한다고 결론을 내릴 수 있습니다. 그러면 나중에 의존성 주입 시 오류가 발생할 것입니다. (ProductCode Wrapper 타입을 파라미터로 사용하기 때문)
 

Order의 유효성 검사 구현

검증 단계는 모든 기본 필드와 함께 검증되지 않은 데이터를 취하고 이를 적절하고 완전히 검증된 도메인 객체로 변환합니다.
이 단계의 함수 타입을 다음과 같이 모델링했습니다.
​ 	​type​ CheckAddressExists =
​ 	  UnvalidatedAddress -> AsyncResult<CheckedAddress,AddressValidationError>
​ 	
​ 	​type​ ValidateOrder =
​ 	  CheckProductCodeExists    ​// dependency​
​ 	    -> CheckAddressExists   ​// AsyncResult dependency​
​ 	    -> UnvalidatedOrder     ​// input​
​ 	    -> AsyncResult<ValidatedOrder,ValidationError ​list​>  ​// output

이번에는 이펙트 없이 순수 함수들만 조합할 것이기에, Async와 Result 타입을 제거합니다.
​ 	​type​ CheckAddressExists =
​ 	  UnvalidatedAddress -> CheckedAddress
​ 	
​ 	​type​ ValidateOrder =
​ 	  CheckProductCodeExists    ​// dependency​
​ 	    -> CheckAddressExists   ​// dependency​
​ 	    -> UnvalidatedOrder     ​// input​
​ 	    -> ValidatedOrder       ​// output

// ValidateOrder에 인자 2개만 주입하면 CheckAddressExists가 됩니다!!

 

이것을 구현으로 변환해 보겠습니다. UnvalidatedOrder에서 ValidatedOrder를 만드는 단계는 다음과 같습니다.
실제로 API 호출 시 고려하는 입력은 Unvalidated Order 뿐입니다. 즉 순수하게 검증에만 집중하는 로직입니다.

  • UnvalidatedOrder의 OrderId 문자열에서 OrderId 도메인 타입을 생성합니다.
  • UnvalidatedOrder의 UnvalidatedCustomerInfo 필드에서 CustomerInfo 도메인 타입을 생성합니다.
  • UnvalidatedAddress 타입인 UnvalidatedOrder의 ShippingAddress 필드에서 Address 도메인 타입을 생성합니다.
  • BillingAddress 및 기타 모든 속성에 대해 동일한 작업을 수행합니다.
  • ValidatedOrder의 모든 구성 요소를 사용할 수 있게 되면 일반적인 방법으로 레코드를 만들 수 있습니다.
코드로 보면 다음과 같습니다.
​ 	​let​ validateOrder : ValidateOrder =
​ 	  ​fun​ checkProductCodeExists checkAddressExists unvalidatedOrder ->
​ 	
​ 	    ​let​ orderId =
​ 	      unvalidatedOrder.OrderId
​ 	      |> OrderId.create
​ 	
​ 	    ​let​ customerInfo =
​ 	      unvalidatedOrder.CustomerInfo
​ 	      |> toCustomerInfo   ​// helper function​
​ 	
​ 	    ​let​ shippingAddress =
​ 	      unvalidatedOrder.ShippingAddress
​ 	      |> toAddress        ​// helper function​
​ 	
​ 	    ​// and so on, for each property of the unvalidatedOrder​
​ 	
​ 	    ​// when all the fields are ready, use them to​
​ 	    ​// create and return a new "ValidatedOrder" record​
​ 	    {
​ 	      OrderId = orderId
​ 	      CustomerInfo = customerInfo
​ 	      ShippingAddress = shippingAddress
​ 	      BillingAddress = ...
​ 	      Lines = ...
​ 	    }​


toCustomerInfo 및 toAddress와 같이 아직 정의하지 않은 일부 헬퍼 함수를 사용하고 있음을 알 수 있습니다.
이러한 함수는 검증되지 않은 타입에서 도메인 타입을 생성합니다.
예를 들어 toAddress는 UnvalidatedAddress 타입을 해당 도메인 타입인 Address로 변환하고
UnvalidatedAddress의 기본 값이 제약 조건(예: null이 아니고 길이가 50자 미만인 경우)을 충족하지 않으면 오류를 발생시킵니다.
헬퍼 함수가 있으면 UnvalidatedOrder(또는 비도메인 타입)를 도메인 타입으로 변환하는 논리가 간단합니다.
도메인 타입(이 경우 ValidatedOrder)의 각 필드에 대해 해당 필드를 찾아,
도메인 타입이 아닌 타입의(UnvalidatedOrder) 필드를 도메인 타입으로 변환하기 위해 헬퍼 함수 중 하나를 사용합니다.
 
Order의 하위 구성 요소를 변환할 때도 동일한 접근 방식을 사용할 수 있습니다.
예를 들어 다음은 UnvalidatedCustomerInfo에서 CustomerInfo를 빌드하는 toCustomerInfo의 구현입니다.
 
	​let​ toCustomerInfo (customer:UnvalidatedCustomerInfo) : CustomerInfo =
​ 	  ​// create the various CustomerInfo properties​
​ 	  ​// and throw exceptions if invalid​
​ 	  ​let​ firstName = customer.FirstName |> String50.create
​ 	  ​let​ lastName = customer.LastName |> String50.create
​ 	  ​let​ emailAddress = customer.EmailAddress |> EmailAddress.create
​ 	
​ 	  ​// create a PersonalName​
​ 	  ​let​ name : PersonalName = {
​ 	    FirstName = firstName
​ 	    LastName = lastName
​ 	    }
​ 	
​ 	  ​// create a CustomerInfo​
​ 	  ​let​ customerInfo : CustomerInfo = {
​ 	    Name = name
​ 	    EmailAddress = emailAddress
​ 	    }
​ 	  ​// ... and return it​
​ 	  customerInfo​

 

유효한 CheckedAddress 만들기

toAddress 함수는 primitive type(unvalidatedAddress : string)을 도메인 객체로 변환해야 할 뿐만 아니라
주소가 실제로 존재하는지 확인해야 하기 때문에(CheckAddressExists 서비스를 사용하여) 조금 더 복잡합니다. (의존성)
주석이 포함된 완전한 구현은 다음과 같습니다.
​ 	​let​ toAddress (checkAddressExists:CheckAddressExists) unvalidatedAddress =
​ 	  ​// call the remote service​
​ 	  ​let​ checkedAddress = checkAddressExists unvalidatedAddress
​ 	  ​// extract the inner value using pattern matching​
​ 	  ​let​ (CheckedAddress checkedAddress) = checkedAddress
​ 	
​ 	  ​let​ addressLine1 =
​ 	    checkedAddress.AddressLine1 |> String50.create
​ 	  ​let​ addressLine2 =
​ 	    checkedAddress.AddressLine2 |> String50.createOption
​ 	  ​let​ addressLine3 =
​ 	    checkedAddress.AddressLine3 |> String50.createOption
​ 	  ​let​ addressLine4 =
​ 	    checkedAddress.AddressLine4 |> String50.createOption
​ 	  ​let​ city =
​ 	    checkedAddress.City |> String50.create
​ 	  ​let​ zipCode =
​ 	    checkedAddress.ZipCode |> ZipCode.create
​ 	  ​// create the address​
​ 	  ​let​ address : Address = {
​ 	    AddressLine1 = addressLine1
​ 	    AddressLine2 = addressLine2
​ 	    AddressLine3 = addressLine3
​ 	    AddressLine4 = addressLine4
​ 	    City = city
​ 	    ZipCode = zipCode
​ 	    }
​ 	  ​// return the address​
​ 	  address​
String50 모듈의 다른 생성자 함수(String50.createOption)를 참조하고 있다는 점에 유의하세요.
이 함수는 입력 필드가 null이거나 empty일 수 있고, 이 경우 None을 반환합니다.
toAddress 함수는 checkAddressExists를 호출해야 하므로 매개변수로 추가했습니다. (의존성)
이제 상위 validateOrder 함수에서 해당 함수를 전달합니다.
​ 	​let​ validateOrder : ValidateOrder =
​ 	  ​fun​ checkProductCodeExists checkAddressExists unvalidatedOrder ->
​ 	
​ 	    ​let​ orderId = ...
​ 	    ​let​ customerInfo = ...
​ 	    ​let​ shippingAddress =
​ 	      unvalidatedOrder.ShippingAddress
​ 	      |> toAddress checkAddressExists ​// new parameter​
​ 	
​ 	    ...

 

두 개의 매개변수가 있는 경우 toAddress에 하나의 매개변수만 전달하는 이유가 궁금할 것입니다.
부분 적용을 활용하여 두 번째 매개변수(배송 주소)는 파이핑 프로세스를 통해 제공됩니다. 
 

주문 라인 리스트 생성

주문 라인 리스트를 생성하는 것은 더 복잡합니다. 먼저 단일 UnvalidatedOrderLine을 ValidatedOrderLine으로 변환하는 방법이 필요합니다. ValidatedOrderLine이라고 부르겠습니다.

​ 	​let​ toValidatedOrderLine checkProductCodeExists
​ 	(unvalidatedOrderLine:UnvalidatedOrderLine) =
​ 	  ​let​ orderLineId =
​ 	    unvalidatedOrderLine.OrderLineId
​ 	    |> OrderLineId.create
​ 	  ​let​ productCode =
​ 	    unvalidatedOrderLine.ProductCode
​ 	    |> toProductCode checkProductCodeExists ​// helper function​
​ 	  ​let​ quantity =
​ 	    unvalidatedOrderLine.Quantity
​ 	    |> toOrderQuantity productCode  ​// helper function​
​ 	  ​let​ validatedOrderLine = {
​ 	    OrderLineId = orderLineId
​ 	    ProductCode = productCode
​ 	    Quantity = quantity
​ 	    }
​ 	  validatedOrderLine
위의 toAddress 함수와 유사합니다. (checkProductCodeExist 의존성을 파라미터로 받아 파이프에서 부분 적용 사용)
두 가지 헬퍼 함수인 toProductCode와 toOrderQuantity가 있으며 이에 대해서는 곧 논의하겠습니다.

 

모든 필드를 변환하는 방법을 구현했으므로, List.map(C# LINQ의 Select와 동일)을 사용하여
전체 ValidatedOrderLines 목록을 한 번에 변환할 수 있습니다.
​ 	​let​ validateOrder : ValidateOrder =
​ 	  ​fun​ checkProductCodeExists checkAddressExists unvalidatedOrder ->
​ 	
​ 	    ​let​ orderId = ...
​ 	    ​let​ customerInfo = ...
​ 	    ​let​ shippingAddress = ...
​ 	
​ 	    ​let​ orderLines =
​ 	      unvalidatedOrder.Lines
​ 	      ​// convert each line using `toValidatedOrderLine`​
​ 	      |> List.map (toValidatedOrderLine checkProductCodeExists)
​ 	    ...
toOrderQuantity 헬퍼 함수를 살펴보겠습니다.
이것은 바운디드 컨텍스트 경계에서 검증의 좋은 예입니다.
입력은 UnvalidatedOrderLine의 검증되지 않은 primitive decimal이지만
출력(OrderQuantity)은 각 경우에 대해 검증이 다른 초이스 타입입니다. 코드는 다음과 같습니다.
​ 	​let​ toOrderQuantity productCode quantity =
​ 	  ​match​ productCode ​with​
​ 	  | Widget _ ->
​ 	    quantity
​ 	    |> ​int​                  ​// convert decimal to int​
​ 	    |> UnitQuantity.create  ​// to UnitQuantity​
​ 	    |> OrderQuantity.Unit   ​// lift to OrderQuantity type​
​ 	  | Gizmo _ ->
​ 	    quantity
​ 	    |> KilogramQuantity.create  ​// to KilogramQuantity​
​ 	    |> OrderQuantity.Kilogram   ​// lift to OrderQuantity type
ProductCode 초이스 타입을 이용하여 생성자를 가이드합니다..
예를 들어 ProductCode가 Widget이면 primitive decimal를 int로 변환하고 UnitQuantity를 만듭니다.
GizmoCode의 경우에도 유사합니다. (int 변환 제외)
 
이 경우 한 분기는 UnitQuantity를 반환하고 다른 분기는 KilogramQuantity를 반환합니다.
이들은 다른 타입이므로 컴파일러 오류가 발생합니다.
두 가지를 모두 초이스 타입 OrderQuantity로 변환하여 두 가지 모두 동일한 타입을 반환하게 하여 오류를 제거합니다.
 
다른 헬퍼 함수인 toProductCode는 언뜻 보기에 구현이 간단해 보입니다. 되도록 많은 함수를 조합해 파이프라인을 만드는 것이 좋습니다.
​ 	​let​ toProductCode (checkProductCodeExists:CheckProductCodeExists) productCode =
​ 	  productCode
​ 	  |> ProductCode.create
​ 	  |> checkProductCodeExists
​ 	  ​// returns a bool :(
문제가 생겼습니다. toProductCode 함수가 ProductCode를 반환하기를 원하지만 checkProductCodeExists 함수는 bool을 반환합니다. 이는 전체 파이프라인이 bool을 반환함을 의미합니다. 어떻게든 checkProductCodeExists가 대신 ProductCode를 반환하도록 해야 합니다. 이런, 스펙을 바꿔야 한다는 뜻인가요? 다행히 방법이 있습니다.
 
(주 : 위의 CheckAddressExist 종속성은 Wrapping된 주소 자체를 반환했었습니다. 

어댑터 함수 만들기 (Creating Function Adapters)

bool을 반환하는 함수가 있지만 원래 ProductCode 입력을 반환하는 함수가 필요합니다.
사양(타입 목록)을 변경하는 대신
원래 함수를 입력으로 사용하고 파이프라인에서 사용할 올바른 "모양"으로 새 함수를 내보내는 "어댑터" 함수를 만들어 보겠습니다.
어댑터 함수

다음은 bool 반환 predicate(checkProductCodeExists) 및 확인할 값(productCode) 매개변수가 있는 명백한 구현입니다.

​ 	​let​ convertToPassthru checkProductCodeExists productCode =
​ 	  ​if​ checkProductCodeExists productCode ​then​
​ 	    productCode
​ 	  ​else​
​ 	    failwith ​"Invalid Product Code"


흥미로운 점은 컴파일러가 이 구현이 제네릭 하다고 판단했다는 것입니다.
함수 서명을 보면 어디에도 ProductCode 타입에 대한 언급이 없음을 알 수 있습니다.
​ 	​val​ convertToPassthru :
​ 	  checkProductCodeExists:(​'​a -> ​bool​) -> productCode:​'​a -> ​'​a​
사실, 우리는 predicate를 파이프라인에 적합한 "passthrough" 함수로 변환하는 제네릭 어댑터를 실수로 만들었습니다. "checkProductCodeExists" 또는 "productCode" 매개변수를 호출하는 것은 이제 의미가 없습니다.
이것이 많은 표준 라이브러리 함수가 함수 매개변수에 대해 f 및 g, 다른 값에 대해 x 및 y와 같이 짧은 매개변수 이름을 갖는 이유입니다.
다음과 같이 더 추상적인 이름을 사용하도록 함수를 다시 작성해 보겠습니다.
(주 : 타입스크립트의 경우 (x:T)=>boolean과 x:T 를 연달아 인자로 받고 T를 리턴하는 함수가 되겠네요!)
​ 	​let​ predicateToPassthru f x =
​ 	  ​if​ f x ​then​
​ 	    x
​ 	  ​else​
​ 	    failwith ​"Invalid Product Code"​
이제 하드 코딩된 오류 메시지가 표시되므로 이것도 매개변수화해 보겠습니다. 최종 버전은 다음과 같습니다.
​ 	​let​ predicateToPassthru errorMsg f x =
​ 	  ​if​ f x ​then​
​ 	    x
​ 	  ​else​
​ 	    failwith errorMsg
오류 메시지를 매개변수 순서에서 가장 먼저 넣었다는 점에 유의하십시오.
이는 부분 적용으로 오류 메시지를 적용할 수 있도록 하기 위함입니다. 이 함수의 서명은 다음과 같습니다.
​ 	​val​ predicateToPassthru : errorMsg:​string​ -> f:(​'​a -> ​bool​) -> x:​'​a -> ​'​a
우리는 이것을 "당신은 나에게 오류 메시지와 'a -> bool 유형의 함수를 제공하고, 나는 'a -> 'a 유형의 함수를 돌려줄 것입니다."로 해석할 수 있습니다. 따라서 이 predicateToPassthru 함수는 "함수 변환기"입니다. 하나의 함수에 입력하면 다른 함수로 변환됩니다.
(주 : x까지 3개의 인자를 동시에 제공할 수도 있습니다.)
 
이 기술은 함수형 프로그래밍에서 매우 일반적이므로 무슨 일이 일어나고 있는지 이해하고 패턴을 볼 때 인식하는 것이 중요합니다.
소박한 List.map 함수도 함수 변환기로 생각할 수 있습니다. "일반" 함수 'a -> 'b를 목록에서 작동하는 함수('a list -> 'b list)로 변환합니다.
 
이제 이 일반 함수를 구현에 사용할 수 있는 새 버전의 toProductCode를 생성해 보겠습니다.
​ 	​let​ toProductCode (checkProductCodeExists:CheckProductCodeExists) productCode =
​ 	
​ 	  ​// create a local ProductCode -> ProductCode function​
​ 	  ​// suitable for using in a pipeline​
​ 	  ​let​ checkProduct productCode =
		// 에러 메세지 표현식
​ 	    ​let​ errorMsg = sprintf ​"Invalid: %A"​ productCode
		// 위의 에러 메세지를 인자로 제공
​ 	    predicateToPassthru errorMsg checkProductCodeExists productCode
​ 	
​ 	  ​// assemble the pipeline​
​ 	  productCode
​ 	  |> ProductCode.create
​ 	  |> checkProduct
 

이제 우리는 구축할 수 있는 validateOrder 구현의 기본 스케치를 갖게 되었습니다.
"제품은 W 또는 G로 시작해야 함"과 같은 저수준 유효성 검사 논리는 유효성 검사 함수에서 명시적으로 구현되지 않고
OrderId 및 ProductCode와 같은 제한된 단순 타입의 생성자에 내장되어 있습니다.
타입을 사용하면 코드가 정확하다는 확신을 높일 수 있습니다.
UnvalidatedOrder에서 ValidatedOrder를 성공적으로 생성할 수 있다는 사실 자체가 유효성이 검증되었다는 것을 신뢰할 수 있다는 것을 의미합니다!
 

나머지 스텝 구현하기

이제 validateOrder를 구현하는 방법을 보았으므로 동일한 기술을 사용하여 나머지 파이프라인 함수를 빌드할 수 있습니다.
효과가 있는 가격 책정 단계 함수의 원래 디자인은 다음과 같습니다.
​ 	​type​ PriceOrder =
​ 	  GetProductPrice      ​// dependency​
​ 	    -> ValidatedOrder  ​// input​
​ 	    -> Result<PricedOrder, PlaceOrderError>  ​// output

 

이번에도 효과는 고려하지 않습니다.
​ 	​type​ GetProductPrice = ProductCode -> Price
​ 	​type​ PriceOrder =
​ 	  GetProductPrice      ​// dependency​
​ 	    -> ValidatedOrder  ​// input​
​ 	    -> PricedOrder     ​// output

 

다음은 구현 개요입니다. 각 주문 라인을 PricedOrderLine으로 변환하고 새로운 PricedOrder를 빌드합니다.
(getProductPrice는 외부 의존성입니다.)
​ 	​let​ priceOrder : PriceOrder =
​ 	  ​fun​ getProductPrice validatedOrder ->
​ 	    ​let​ lines =
​ 	      validatedOrder.Lines
​ 	      |> List.map (toPricedOrderLine getProductPrice)
​ 	    ​let​ amountToBill =
​ 	      lines
​ 	      ​// get each line price​
​ 	      |> List.map (​fun​ line -> line.LinePrice)
​ 	      ​// add them together as a BillingAmount​
​ 	      |> BillingAmount.sumPrices
​ 	    ​let​ pricedOrder : PricedOrder = {
​ 	      OrderId  = validatedOrder.OrderId
​ 	      CustomerInfo = validatedOrder.CustomerInfo
​ 	      ShippingAddress = validatedOrder.ShippingAddress
​ 	      BillingAddress = validatedOrder.BillingAddress
​ 	      Lines = lines
​ 	      AmountToBill = amountToBill
​ 	      }
​ 	    pricedOrder
파이프라인에 많은 단계가 있고 나중에 구현하려는 경우(또는 방법을 모르는 경우) 다음과 같이 "구현되지 않음" 메시지와 함께 실패할 수 있습니다.
 	​let​ priceOrder : PriceOrder =
​ 	  ​fun​ getProductPrice validatedOrder ->
​ 	    failwith ​"not implemented"
구현을 스케치할 때 "구현되지 않음" 예외를 사용하는 것이 편리할 수 있습니다.
이를 통해 우리 프로젝트가 항상 완전히 컴파일 가능하도록 할 수 있습니다.
접근 방식을 사용하여 함수 타입을 준수하는 특정 파이프라인 단계의 더미 버전을 빌드한 다음 적절한 구현을 사용할 수 있기 전에 다른 단계와 함께 사용할 수 있습니다.
priceOrder 구현으로 돌아가서 두 가지 새로운 도우미 함수인 toPricedOrderLine 및 BillingAmount.sumPrices를 도입했습니다.
BillingAmount.sumPrices 함수를 공유 BillingAmount 모듈에 추가했습니다. (create 함수 및 value 함수와 함께)
단순히 가격 목록을 추가하고 BillingAmount로 래핑합니다.
처음에 BillingAmount 타입을 정의한 이유는 무엇입니까?
계산 결과가 가격과 다르고 유효성 검사 규칙이 다를 수 있기 때문입니다.
​ 	​/// Sum a list of prices to make a billing amount​
​ 	​/// Raise exception if total is out of bounds​
//     type GetProductPrice = ProductCode -> Price
​ 	​let​ sumPrices prices =
​ 	  ​let​ total = prices |> List.map Price.value |> List.sum
​ 	  create total

toPricedOrderLine 함수는 이전에 본 것과 유사합니다. 한 줄만 변환하는 도우미 함수입니다.
그리고 이 함수 안에 Price.multiply라는 또 다른 도우미 함수를 도입하여 Price에 수량을 곱했습니다.
​ 	​/// Transform a ValidatedOrderLine to a PricedOrderLine​
​ 	​let​ toPricedOrderLine getProductPrice (line:ValidatedOrderLine) : PricedOrderLine =
​ 	  ​let​ qty = line.Quantity |> OrderQuantity.value
​ 	  ​let​ price = line.ProductCode |> getProductPrice
​ 	  ​let​ linePrice = price |> Price.multiply qty
​ 	  {
​ 	    OrderLineId = line.OrderLineId
​ 	    ProductCode = line.ProductCode
​ 	    Quantity = line.Quantity
​ 	    LinePrice = linePrice
​ 	  }
module Price =
      let multiply qty (Price p) = 
            create (qty * p)
이제 가격 책정 단계가 완료되었습니다!

승인 단계 구현

이펙트가 제거된 승인 단계의 디자인은 다음과 같습니다.
​ 	​type​ HtmlString = HtmlString ​of​ ​string​
​ 	​type​ CreateOrderAcknowledgmentLetter =
​ 	  PricedOrder -> HtmlString
​ 	
​ 	​type​ OrderAcknowledgment = {
​ 	  EmailAddress : EmailAddress
​ 	  Letter : HtmlString
​ 	  }
​ 	​type​ SendResult = Sent | NotSent
​ 	​type​ SendOrderAcknowledgment =
​ 	  OrderAcknowledgment -> SendResult
​ 	
​ 	​type​ AcknowledgeOrder =
​ 	  CreateOrderAcknowledgmentLetter     ​// dependency​
​ 	    -> SendOrderAcknowledgment        ​// dependency​
​ 	    -> PricedOrder                    ​// input​
​ 	    -> OrderAcknowledgmentSent option ​// output​
구현은 다음과 같습니다.
​ 	​let​ acknowledgeOrder : AcknowledgeOrder =
​ 	  ​fun​ createAcknowledgmentLetter sendAcknowledgment pricedOrder ->
​ 	    ​let​ letter = createAcknowledgmentLetter pricedOrder
​ 	    ​let​ acknowledgment = {
​ 	      EmailAddress = pricedOrder.CustomerInfo.EmailAddress
​ 	      Letter = letter
​ 	      }
​ 	
​ 	    ​// if the acknowledgment was successfully sent,​
​ 	    ​// return the corresponding event, else return None​
​ 	    ​match​ sendAcknowledgment acknowledgment ​with​
​ 	    | Sent ->
​ 	      ​let​ ​event​ = {
​ 	        OrderId = pricedOrder.OrderId
​ 	        EmailAddress = pricedOrder.CustomerInfo.EmailAddress
​ 	        }
​ 	      Some ​event​
​ 	    | NotSent ->
​ 	      None
도우미 함수가 필요하지 않고 구현이 간단합니다! (실제론 이펙트가 숨어있습니다...)
하지만 sendAcknowledgement 의존성은 어떻습니까?
어느 시점에서 우리는 구현을 결정해야 합니다.
그러나 지금은 그냥 내버려 둘 수 있습니다.
이는 함수를 사용하여 종속성을 매개변수화하는 것의 큰 이점 중 하나입니다.
마지막 순간까지 결정을 내리는 것을 피할 수 있지만 여전히 대부분의 코드를 빌드하고 어셈블할 수 있습니다.

이벤트 생성하기

마지막으로 워크플로에서 반환할 이벤트를 만들기만 하면 됩니다.

요구 사항에 청구 가능 금액이 0보다 클 때만 청구 이벤트를 보내야 한다고 가정해 보겠습니다. 디자인은 다음과 같습니다.

​ 	​/// Event to send to shipping context​
​ 	​type​ OrderPlaced = PricedOrder
​ 	
​ 	​/// Event to send to billing context​
​ 	​/// Will only be created if the AmountToBill is not zero​
​ 	​type​ BillableOrderPlaced = {
​ 	    OrderId : OrderId
​ 	    BillingAddress: Address
​ 	    AmountToBill : BillingAmount
​ 	    }
​ 	
​ 	​type​ PlaceOrderEvent =
​ 	    | OrderPlaced ​of​ OrderPlaced
​ 	    | BillableOrderPlaced ​of​ BillableOrderPlaced
​ 	    | AcknowledgmentSent  ​of​ OrderAcknowledgmentSent
​ 	
​ 	​type​ CreateEvents =
​ 	  PricedOrder                            ​// input​
​ 	    -> OrderAcknowledgmentSent option    ​// input (event from previous step)​
​ 	    -> PlaceOrderEvent ​list​​
PricedOrder 이벤트와 동일하기 때문에 OrderPlaced 이벤트를 생성할 필요가 없습니다.
OrderAcknowledgementSent 이벤트는 이전 단계에서 생성되었으므로 생성할 필요가 없습니다.
하지만 BillableOrderPlaced 이벤트가 필요하므로 createBillingEvent 함수를 만들어 보겠습니다.
그리고 0이 아닌 청구 금액을 테스트해야 하기 때문에 함수는 Optional 이벤트를 반환해야 합니다.
​ 	​// PricedOrder -> BillableOrderPlaced option​
​ 	​let​ createBillingEvent (placedOrder:PricedOrder) : BillableOrderPlaced option =
​ 	  ​let​ billingAmount = placedOrder.AmountToBill |> BillingAmount.value
​ 	  ​if​ billingAmount > 0M ​then​
​ 	    ​let​ order = {
​ 	      OrderId = placedOrder.OrderId
​ 	      BillingAddress = placedOrder.BillingAddress
​ 	      AmountToBill = placedOrder.AmountToBill
​ 	    }
​ 	    Some order
​ 	  ​else​
​ 	    None​

이제 OrderPlaced 이벤트, Optional OrderAcknowledgementSent 이벤트 및 Optional BillableOrderPlaced가 있습니다.

어떻게 반환해야 합니까?

우리는 모든 것을 공통 유형으로 변환하기 위해 "최소 공배수" 접근 방식을 사용할 것입니다.

우리는 전부를 포함한 초이스 타입(PlaceOrderEvent)을 생성한 다음 그 리스트를 반환하기로 결정했습니다.

따라서 먼저 각 이벤트를 초이스 타입으로 변환해야 합니다.

OrderPlaced 이벤트의 경우 PlaceOrderEvent.OrderPlaced 생성자를 직접 사용할 수 있지만

OrderAcknowledgementSent 및 BillableOrderPlaced의 경우 선택 사항이므로 Option.map을 사용해야 합니다.

​ 	​let​ createEvents : CreateEvents =
​ 	  ​fun​ pricedOrder acknowledgmentEventOpt ->
​ 	    ​let​ event1 =
​ 	      pricedOrder
​ 	      ​// convert to common choice type​
​ 	      |> PlaceOrderEvent.OrderPlaced
​ 	    ​let​ event2Opt =
​ 	      acknowledgmentEventOpt
​ 	      ​// convert to common choice type​
​ 	      |> Option.map PlaceOrderEvent.AcknowledgmentSent
​ 	    ​let​ event3Opt =
​ 	      pricedOrder
​ 	      |> createBillingEvent
​ 	      ​// convert to common choice type​
​ 	      |> Option.map PlaceOrderEvent.BillableOrderPlaced
​ 	
​ 	    ​// return all the events how?​
​ 	    ...
이제 모두 같은 초이스 타입이지만, 일부는 Option 타입에 감싸져있네요.
우리는 같은 트릭을 다시 수행합니다. 즉 Option<타입>을
다시 일반적인 타입으로 변환할 것입니다. 이 경우에는 리턴 타입인 리스트 입니다.
OrderPlaced의 경우 List.singleton을 사용하여 리스트로 변환할 수 있으며 옵션의 경우 listOfOption이라는 도우미를 만들 수 있습니다.
​ 	​/// convert an Option into a List​
​ 	​let​ listOfOption opt =
​ 	  ​match​ opt ​with​
​ 	  | Some x -> [x]
​ 	  | None -> []
타입 리뷰 
    type OrderPlaced = PricedOrder

    /// Event to send to billing context
    /// Will only be created if the AmountToBill is not zero
    type BillableOrderPlaced = {
        OrderId : OrderId
        BillingAddress: Address
        AmountToBill : BillingAmount
        }

    type PlaceOrderEvent = 
        | OrderPlaced of OrderPlaced
        | BillableOrderPlaced of BillableOrderPlaced 
        | AcknowledgmentSent  of OrderAcknowledgmentSent

    type CreateEvents = 
      PricedOrder                            // input
        -> OrderAcknowledgmentSent option    // input (event from previous step)
        -> PlaceOrderEvent list              // output
    //<
이제 세 가지 이벤트 타입이 모두 동일하고, 각각 다른 목록에 반환할 수 있습니다.
​ 	​let​ createEvents : CreateEvents =
​ 	  ​fun​ pricedOrder acknowledgmentEventOpt ->
​ 	    ​let​ events1 =
​ 	      pricedOrder
​ 	      ​// convert to common choice type​
​ 	      |> PlaceOrderEvent.OrderPlaced
​ 	      ​// convert to list​
​ 	      |> List.singleton
​ 	    ​let​ events2 =
​ 	      acknowledgmentEventOpt
​ 	      ​// convert to common choice type​
​ 	      |> Option.map PlaceOrderEvent.AcknowledgmentSent
​ 	      ​// convert to list​
​ 	      |> listOfOption
​ 	    ​let​ events3 =
​ 	      pricedOrder
​ 	      |> createBillingEvent
​ 	      ​// convert to common choice type​
​ 	      |> Option.map PlaceOrderEvent.BillableOrderPlaced
​ 	      ​// convert to list​
​ 	      |> listOfOption
​ 	
​ 	    ​// return all the events​
​ 	    [
​ 	    ​yield​! events1
​ 	    ​yield​! events2
​ 	    ​yield​! events3
​ 	    ]

 

호환되지 않는 항목들을 공유 타입으로 변환하거나 "리프팅"하는 이러한 접근 방식은 함수 합성 문제를 처리하는 핵심 기술입니다.
예를 들어, 다음 장에서는 다른 종류의 Result type 간의 불일치를 처리하는 데 사용할 것입니다.
반응형