본문 바로가기

BackEnd

DDD 구현 : 파이프라인에 모나드 적용하기, computation expression

반응형

https://itchallenger.tistory.com/428

 

DDD 구현 : 모나드로 Result 생성 함수 연결

Result type을 생성하는 함수끼리 깔끔하게 합성하는 법을 알아봅시다. 다음은 문제의 시각적 표현입니다. 일반 함수는 철도 트랙의 일부로 시각화할 수 있습니다. 그러나 Result 출력이 있는 함수는

itchallenger.tistory.com

이전 게시물에서 모나드를 설명했습니다.

 

 비동기 효과와 서비스 종속성을 무시하면서 파이프라인의 구성 요소를 빠르게 다시 살펴보겠습니다.
 
첫째, ValidateOrder는 입력 데이터가 올바른 형식이 아닌 경우 오류를 반환하므로 "switch" 함수이고 서명은 다음과 같습니다. 
(switch 함수는 생성자입니다~)
 	​type​ ValidateOrder =
​ 	  ​// ignoring additional dependencies for now​
​ 	  UnvalidatedOrder                             ​// input​
​ 	    -> Result<ValidatedOrder, ValidationError> ​// outp
PriceOrder 단계도 다양한 이유로 실패할 수 있으므로 서명은 다음과 같습니다.
​ 	​type​ PriceOrder =
​ 	  ValidatedOrder                          ​// input​
​ 	    -> Result<PricedOrder, PricingError>  ​// output
AcknowledgeOrder 및 CreateEvents 단계는 항상 성공하므로 서명은 다음과 같습니다.
​ 	​type​ AcknowledgeOrder =
​ 	  PricedOrder                         ​// input​
​ 	    -> OrderAcknowledgmentSent option ​// output​
​ 	
​ 	​type​ CreateEvents =
​ 	  PricedOrder                            ​// input​
​ 	    -> OrderAcknowledgmentSent option    ​// input (event from previous step)​
​ 	    -> PlaceOrderEvent ​list​              ​// output
ValidateOrder와 PriceOrder를 결합하여 시작하겠습니다.
ValidateOrder의 실패 타입은 ValidationError이고 PriceOrder의 실패 유형은 PricingError입니다.
위에서 보았듯이 오류 타입이 다르기 때문에 함수가 호환되지 않습니다.
동일한 오류 타입을 반환하도록 두 함수를 모두 변환해야 합니다.
이는 파이프라인 전체에서 사용되는 일반적인 오류 타입이며 이를 PlaceOrderError라고 합니다.
PlaceOrderError는 다음과 같이 정의됩니다.
​ 	​type​ PlaceOrderError =
​ 	  | Validation ​of​ ValidationError
​ 	  | Pricing ​of​ PricingError
이제 mapError를 사용하여 위의 FruitError 예제에서 했던 것처럼 구성할 수 있는 새 버전의 validateOrder 및 priceOrder를 정의할 수 있습니다.
(이 친구들은 Result 생성자와 같은 역할을 합니다.)
​ 	​// Adapted to return a PlaceOrderError​
​ 	​let​ validateOrderAdapted input =
​ 	  input
​ 	  |> validateOrder ​// the original function​
​ 	  |> Result.mapError PlaceOrderError.Validation
​ 	
​ 	​// Adapted to return a PlaceOrderError​
​ 	​let​ priceOrderAdapted input =
​ 	  input
​ 	  |> priceOrder ​// the original function​
​ 	  |> Result.mapError PlaceOrderError.Pricing

(주 : mapError는 2track Result를 반환하므로 Ok면 그대로 Ok를 리턴합니다.)

이 작업이 완료되면 bind를 사용하여  연결할 수 있습니다.

(bind는 생성자 함수인 Switch 함수와 Result 값을 인자로 받습니다.)

​ 	​let​ placeOrder unvalidatedOrder =
​ 	  unvalidatedOrder
​ 	  |> validateOrderAdapted             ​// adapted version​
​ 	  |> Result.bind priceOrderAdapted ​// adapted version
validateOrderAdapted 함수는 파이프라인의 첫 번째이기 때문에 앞에 bind를 가질 필요가 없습니다. (처음 Result로 Lift함 - 1 track 함수)
다음으로,acknowledgeOrder 및 createEvents는 "원 트랙" 함수이므로 오류가 없으므로
Result.map을 사용하여 파이프라인에 삽입할 수 있는 2트랙 함수로 변환할 수 있습니다.
​ 	​let​ placeOrder unvalidatedOrder =
​ 	  unvalidatedOrder
​ 	  |> validateOrderAdapted
​ 	  |> Result.bind priceOrderAdapted
​ 	  |> Result.map acknowledgeOrder   ​// use map to convert to two-track​
​ 	  |> Result.map createEvents       ​// convert to two-track
이 placeOrder 함수에는 다음 서명이 있습니다.
​ 	UnvalidatedOrder -> Result<PlaceOrderEvent ​list​,PlaceOrderError>
이는 우리가 필요로 하는 것에 매우 가깝습니다.
이 새 버전의 워크플로 파이프라인을 분석해 보겠습니다.
 
  • 파이프라인의 각 함수는 오류를 생성할 수 있으며 생성할 수 있는 오류는 서명에 표시됩니다.  우리는 함수를 개별적으로 테스트할 수 있으며, 조립할 때 예기치 않은 동작이 발생하지 않을 것이라고 확신합니다.
  • 함수는 여전히 ​​함께 연결되어 있지만 이제 2트랙 모델을 사용합니다.  한 단계에서 오류가 발생하면 파이프라인의 나머지 함수를 건너뜁니다.
  • 최상위 placeOrder의 전체 흐름은 여전히 ​​깨끗합니다. 특별한 조건문이나 try/catch 블록은 없습니다.
 
불행히도 이 placeOrder 구현은 실제로 컴파일되지 않습니다! bind와 map을 사용하더라도 기능이 항상 맞지는 않습니다.
특히, acknowledgeOrder의 출력은 createEvents의 입력과 일치하지 않습니다.
​ 	​type​ AcknowledgeOrder =
​ 	  PricedOrder                         ​// input​
​ 	    -> OrderAcknowledgmentSent option ​// output​
​ 	
​ 	​type​ CreateEvents =
​ 	  PricedOrder                            ​// input​
​ 	    -> OrderAcknowledgmentSent option    ​// input (event from previous step)​
​ 	    -> PlaceOrderEvent ​list​              ​// output
 
왜냐하면 출력이 PricedOrder가 아니라 이벤트이기 때문입니다. 
 
 

2트랙 모델에 다른 종류의 함수 적용하기

지금까지 파이프라인에서 두 가지 함수 "모양"을 보았습니다. 원 트랙 함수과 "스위치" 함수입니다.

우리는 다른 많은 종류의 함수로 작업해야 할 수도 있습니다.

 

이제 두 가지를 살펴보겠습니다.

  • 예외를 발생시키는 함수 (이전처럼 도메인 오류처럼 예외를 안던지는 경우가 아닌 - 패닉 혹은 인프라 오류)
  • 아무것도 반환하지 않는 "막다른 길(dead-end)" 함수

예외 처리

우리는 코드에서 예외를 던지는 것을 피했지만 라이브러리나 서비스와 같이 우리가 제어하지 않는 코드에서 발생하는 예외는 어떻습니까? 앞서 우리는 많은 예외가 도메인 설계의 일부가 아니며 최상위 수준을 제외하고는 catch할 필요가 없다고 제안했습니다.
그러나 도메인의 일부로 예외를 처리하려면 어떻게 해야 할까요?

솔루션은 간단합니다. 그림과 같이 예외 발생 함수를 Result 반환 함수로 변환하는 또 다른 "어댑터 블록" 함수를 생성하면 됩니다.

Result 반환 함수로 변환하는 또 다른 "어댑터 블록" 함수를 생성하면 됩니다.

예를 들어 원격 서비스의 시간 초과를 잡아서 RemoteServiceError로 바꾸고 싶다고 가정해 보겠습니다.

우리는 많은 서비스와 함께 일할 것이므로 먼저 ServiceInfo를 정의하여 오류를 일으킨 서비스를 추적합니다.

​ 	​type​ ServiceInfo = {
​ 	  Name : ​string​
​ 	  Endpoint: Uri
​ 	  }

그런 다음 이를 기반으로 하는 오류 타입을 정의할 수 있습니다.

​ 	​type​ RemoteServiceError = {
​ 	  Service : ServiceInfo
​ 	  Exception : System.Exception
​ 	  }
ServiceInfo와 원래 서비스 함수를 예외 케이스에 Result를 반환하는 어댑터 블록에 전달합니다.
다음은 서비스 함수가 ​​단일 매개변수(아래 코드의 x)를 취하는 경우의 예입니다.

(전에 다룬 도메인 오류는 Result가 validation 결과였지 예외를 던지는건 아니었음)

 	​/// "Adapter block" that converts exception-throwing services​
​ 	​/// into Result-returning services.​
​ 	​let​ serviceExceptionAdapter serviceInfo serviceFn x =
​ 	  ​try​
​ 	    ​// call the service and return success​
​ 	    Ok (serviceFn x)
​ 	  ​with​
​ 	  | :? TimeoutException ​as​ ex ->
​ 	    Error {Service=serviceInfo; Exception=ex}
​ 	  | :? AuthorizationException ​as​ ex ->
​ 	    Error {Service=serviceInfo; Exception=ex}

가능한 모든 예외를 포착하는 것이 아니라 도메인과 관련된 예외만 포착한다는 점에 유의하십시오.

서비스 함수에 두 개의 매개변수가 있는 경우 해당 경우를 지원하기 위해 다른 어댑터를 정의해야 합니다.

더 많아지면 그때도 마찬가지입니다.

	​let​ serviceExceptionAdapter2 serviceInfo serviceFn x y =
​ 	  ​try​
​ 	    Ok (serviceFn x y)
​ 	  ​with​
​ 	  | :? TimeoutException ​as​ ex -> ...
​ 	  | :? AuthorizationException ​as​ ex -> ...
이들은 모든 함수를을 적용할 일반 어댑터 ​​블록입니다. (serviceInfo는 하나의 타입, serviceFn은 여러 함수 가능)
해당 어댑터 블록은 두가지 에러만 공통적으로 핸들링 하지만,
 
데이터베이스 예외를 "레코드를 찾을 수 없음" 및 "중복 키"와 같은 도메인 친화적 케이스가 있는 DatabaseError 초이스 타입으로 변환하는 것처럼, 커스텀 어댑터를 만들 수 있습니다.
 
이제 이 어댑터를 사용하기 위해 ServiceInfo를 만든 다음 서비스 함수를 전달합니다.
예를 들어 서비스 함수가 주소 확인 함수인 경우 코드는 다음과 같습니다.
 
​ 	​let​ serviceInfo = {
​ 	  Name = ​"AddressCheckingService"​
​ 	  Endpoint = ...
​ 	  }
​ 	
​ 	​// exception-throwing service​
​ 	​let​ checkAddressExists address =
​ 	    ...
​ 	
​ 	​// Result-returning service​
​ 	​let​ checkAddressExistsR address =
​ 	    ​// adapt the service​
​ 	    ​let​ adaptedService =
​ 	      serviceExceptionAdapter serviceInfo checkAddressExists
​ 	    ​// call the service​
​ 	    adaptedService address


새 함수가 Result를 반환하는 변종(variant)이라는 것을 명확히 하기 위해 이름을 checkAddressExistsR로 지정하고 끝에 R을 붙입니다.

(실제 코드에서는 원래 함수와 같은 이름을 지정하여 "shadowing"할 수 있습니다.)

다시 한 번 서명을 확인하여 원하는 것이 있는지 확인하겠습니다.

원래 함수는 항상 CheckedAddress를 반환했음을 나타냅니다.

​ 	checkAddressExists :
​ 	  UnvalidatedAddress -> CheckedAddress

그러나 우리는 서명이 오해의 소지가 있다는 것을 알고 있습니다. 새로운 "adapted" 함수의 서명을 보면 훨씬 더 설명적이라는 것을 알 수 있습니다. 실패하고 오류를 반환할 수 있음을 나타냅니다.

​ 	checkAddressExistsR :
​ 	  UnvalidatedAddress -> Result<CheckedAddress,RemoteServiceError>

오류 타입은 RemoteServiceError이므로 파이프라인에서 이 함수를 사용하려면 PlaceOrderError 타입에 원격 오류에 대한 사례를 추가해야 합니다.

​ 	​type​ PlaceOrderError =
​ 	  | Validation ​of​ ValidationError
​ 	  | Pricing ​of​ PricingError
​ 	  | RemoteService ​of​ RemoteServiceError ​// new!

이전과 마찬가지로 R 버전의 함수를 생성할 때 RemoteServiceError를 공유 PlaceOrderError로 변환해야 합니다.

​ 	​let​ checkAddressExistsR address =
​ 	  ​// adapt the service​
​ 	  ​let​ adaptedService =
​ 	    serviceExceptionAdapter serviceInfo checkAddressExists
​ 	  ​// call the service​
​ 	  address
​ 	  |> adaptedService
​ 	  |> Result.mapError RemoteService ​// lift to PlaceOrderError

Handling Dead-End Functions (막다른 길 함수 다루기)

또 다른 일반적인 유형의 함수는 "dead-end" 또는 "fire-and-forget" 함수라고 부를 수 있는 것입니다.
입력은 받지만 출력은 반환하지 않는 함수입니다.
이러한 종류의 대부분의 함수는 어떻게든 I/O에 쓰고 있습니다. 예를 들어 아래의 로깅 함수에는 출력이 없습니다.
​ 	​// string -> unit​
​ 	​let​ logError msg =
​ 	  printfn ​"ERROR %s"​ msg
다른 예로는 데이터베이스에 쓰기, 대기열에 게시 등이 있습니다.
막다른 길 함수가 2트랙 파이프라인에서 작동하도록 하려면 또 다른 어댑터 블록이 필요합니다.
이것을 합성하려면 먼저 입력으로 막다른 길 함수를 호출한 다음 원래 입력을 반환하는 방법, 즉 "pass-through" 함수가 필요합니다.
이 함수를 tee라고 합시다.
막다른 길 어댑터 블록
​ 	​// ('a -> unit) -> ('a -> 'a)​
​ 	​let​ tee f x =
​ 	  f x
​ 	  x
시그니처는 unit 반환 함수을 사용하고 원 트랙 함수를 리턴한다는 것을 보여줍니다.
그런 다음 Result.map을 사용하여 tee의 출력을 2트랙 함수로 변환할 수 있습니다.
​ 	​// ('a -> unit) -> (Result<'a,'error> -> Result<'a,'error>)​
​ 	​let​ adaptDeadEnd f =
​ 	  Result.map (tee f)
이제 우리는 logError와 같은 막다른 길 함수를 가져와 파이프라인에 삽입할 수 있는 2트랙 함수로 변환할 수 있습니다.

막다른 길 함수에 어댑터 적용

module DeadEnd = 

    //>DeadEnd1
    // string -> unit
    let logError msg =
        printfn "ERROR %s" msg
    //< 
    
    //>DeadEnd2
    // ('a -> unit) -> 'a -> 'a
    let tee f x = 
        f x 
        x
    //< 

    //>DeadEnd3
    // ('a -> unit) -> (Result<'a,'error> -> Result<'a,'error>)
    let adaptDeadEnd f = 
        Result.mapError (tee f)
    //<
    
    //>DeadEnd4
    // val logErrorR:   x: Result<string,'a>   -> Result<string,'a>
    let logErrorR x = 
        (adaptDeadEnd logError) x
    //<
    logErrorR (Ok "hello")

(주 : 저자 제공 코드는 Result.map으로 되어있어서... 함수명은 logError인데 Error가 아니라 Ok를 로깅한다.

위 코드는 동작함으로 https://try.fsharp.org/ 에서 돌려봐도 된다.)


계산 표현식(Computation Expressions)으로 삶을 더 쉽게 만들기

(주 :해당 기능은 F#의 특수한 문법

모나딕한 값을 일반 값처럼 다룰 수 있게 해줌

js의 async 블럭이랑 비슷하게 실제로는 Wrapped된 값인데 일반 값처럼 쓸 수 있게 해줌

(Result랑 Promise랑 유사)

다른 타입들은 Do notation을 찾아봐야 할듯.)

 

지금까지 우리는 간단한 오류 처리 논리를 다루었습니다.

bind를 사용하여 Result 생성 함수를 함께 연결할 수 있었습니다.
그리고 2트랙이 아닌 함수의 경우 다양한 "어댑터" 함수를 사용하여 2트랙 모델에 맞게 만들 수 있었습니다.
(생성자-switch와 flatMap-bind이라 생각하면 됨)
 
그러나 때로는 워크플로 논리가 상당히 복잡합니다.
조건부 분기 또는 루프 내에서 작업하거나 깊이 중첩된 Result 생성 함수로 작업해야 할 수도 있습니다.
이와 같은 경우 F#은 "계산 표현식"의 형태로 약간의 완화를 제공합니다.
계산 표현식은 바인드의 지저분함을 숨기는 특별한 종류의 표현식 블록입니다.
 
자신만의 계산 표현식을 만드는 것은 쉽습니다.
예를 들어 result 라 불리는 Result에 대해 하나를 만들 수 있습니다. 두 가지 함수만 있으면 됩니다.
  • bind - Result와 함께 이미 사용된 함수.
  • return - 값의 생성자입니다. Result의 경우 Ok 생성자가 됩니다.
여기에서는 Result 계산 표현식에 대한 구현 세부 정보를 보여주지 않을 것입니다. 이 책의 코드 리포지토리에 있는 Result.fs 파일에서 코드를 찾아볼 수 있습니다.
대신 Result가 많은 코드를 단순화하기 위해 실제로 계산 표현식을 사용하는 방법을 살펴보겠습니다.
 
placeOrder의 이전 버전에서는 다음과 같이 결과를 반환하는 validateOrder의 출력을 priceOrder의 입력에 연결하기 위해 bind를 사용했습니다.
​ 	​let​ placeOrder unvalidatedOrder =
​ 	  unvalidatedOrder
​ 	  |> validateOrderAdapted
​ 	  |> Result.bind priceOrderAdapted
​ 	  |> Result.map acknowledgeOrder
​ 	  |> Result.map createEvents
계산 표현식을 사용하면 마치 Result에 래핑되지 않은 것처럼 validateOrder 및 priceOrder의 출력으로 직접 작업할 수 있습니다.
다음은 계산 표현식을 사용하여 동일한 코드가 어떻게 보이는지 보여줍니다.​
​ 	​let​ placeOrder unvalidatedOrder =
​ 	  result {
​ 	    ​let​! validatedOrder =
​ 	      validateOrder unvalidatedOrder
​ 	      |> Result.mapError PlaceOrderError.Validation
​ 	    ​let​! pricedOrder =
​ 	      priceOrder validatedOrder
​ 	      |> Result.mapError PlaceOrderError.Pricing
​ 	    ​let​ acknowledgmentOption =
​ 	      acknowledgeOrder pricedOrder
​ 	    ​let​ events =
​ 	      createEvents pricedOrder acknowledgmentOption
​ 	    ​return​ events
​ 	  }
이 코드가 어떻게 작동하는지 봅시다:
  • Result 계산 표현식은 result라는 단어로 시작하여 중괄호{}로 구분된 블록을 포함합니다.
  • let! 키워드는 let처럼 보이지만 사실 내부 값을 얻기 위해 Result를 "unwraps"합니다. let! validatedOrder=...의 validatedOrder 는 priceOrder 함수에 직접 전달할 수 있는 일반 값(Result<'a> 아니고 'a)입니다.
  • error type은 블록 전체에서 동일해야 하므로 Result.mapError를 사용하여 이전과 마찬가지로 error type을 일반 type으로 들어 올립니다. 오류는 결과 표현식에 명시적이지 않지만 해당 type은 여전히 ​​일치해야 합니다.
    • 내부적으로 Error 케이스를 없는것처럼 사용합니다.
  • 블록의 마지막 줄은 블록의 실행 결과를 나타내는 return 키워드를 사용합니다.
바인드를 사용하는 대부분의 장소에서 let!을 사용 가능합니다.
acknowledgeOrder와 같이 bind가 필요하지 않은 다른 함수의 경우 일반 구문을 사용하면 됩니다.
Result.map을 사용할 필요가 없습니다.
보시다시피 계산 표현식은 코드를 Result를 전혀 사용하지 않는 것처럼 보이게 합니다. 복잡함을 멋지게 숨깁니다.
 
계산 표현식을 정의하는 방법에 대해 자세히 설명하지는 않겠지만 매우 간단합니다.
예를 들어, 다음은 위에서 사용된 result 계산 표현식의 기본 정의입니다.
​ 	​type​ ResultBuilder() =
​ 	  ​member​ this.Return(x) = Ok x
​ 	  ​member​ this.Bind(x,f) = Result.bind f x
​ 	
​ 	​let​ result = ResultBuilder()
뒷부분에서 더 많은 계산 표현식, 특히 비동기 콜백을 동일한 우아한 방식으로 관리하는 데 사용되는 비동기 계산 표현식을 볼 것입니다.

 

계산 표현식 합성하기

계산 표현식은 합성 가능합니다.

예를 들어 결과 계산 표현식을 사용하여 validateOrder 및 priceOrder를 정의했다고 가정해 보겠습니다.
​ 	​let​ validateOrder input = result {
​ 	  ​let​! validatedOrder = ... // 실제로는 Result 타입임
​ 	  ​return​ validatedOrder
​ 	  }
​ 	
​ 	​let​ priceOrder input = result {
​ 	  ​let​! pricedOrder = ... // 실제로는 Result 타입임
​ 	  ​return​ pricedOrder
​ 	  }
일반 함수처럼 더 큰 결과 표현식 내에서 사용할 수 있습니다.
​ 	​let​ placeOrder unvalidatedOrder = result {
​ 	  ​let​! validatedOrder = validateOrder unvalidatedOrder
​ 	  ​let​! pricedOrder = priceOrder validatedOrder
​ 	  ...
​ 	  ​return​ ...
​ 	  }
그리고 placeOrder는 더 큰 result 표현식 등에 사용될 수 있습니다.

Validating an Order with Results

이제 validateOrder 함수의 구현을 다시 보겠습니다.
이번에는 오류 처리 논리를 숨기기 위해 result 계산 표현식을 사용합니다.
참고로 다음은 result 없는 구현입니다.
​ 	​let​ validateOrder : ValidateOrder =
​ 	  ​fun​ checkProductCodeExists checkAddressExists unvalidatedOrder ->
​ 	    ​let​ orderId =
​ 	      unvalidatedOrder.OrderId
​ 	      |> OrderId.create
​ 	    ​let​ customerInfo =
​ 	      unvalidatedOrder.CustomerInfo
​ 	      |> toCustomerInfo
​ 	    ​let​ shippingAddress =
​ 	      unvalidatedOrder.ShippingAddress
​ 	      |> toAddress checkAddressExists
​ 	    ​let​ billingAddress = ...
​ 	    ​let​ lines = ...
​ 	
​ 	    ​let​ validatedOrder : ValidatedOrder = {
​ 	      OrderId  = orderId
​ 	      CustomerInfo = customerInfo
​ 	      ShippingAddress = shippingAddress
​ 	      BillingAddress = billingAddress
​ 	      Lines = lines
​ 	    }
​ 	    validatedOrder
Result를 반환하도록 모든 helper 함수를 변경하면 해당 코드는 더 이상 작동하지 않습니다.
예를 들어 OrderId.create 함수는 일반 OrderId가 아닌 Result<OrderId,string>을 반환합니다.
(toCustomerInfo, toAddress 등의 경우에도 유사).
그러나 Result 계산 표현식을 사용하고 let 대신 let! 키워드를 사용하면 OrderId, CustomerInfo를 일반 값처럼 처리할 수 있습니다.
구현은 다음과 같습니다.
​ 	​let​ validateOrder : ValidateOrder =
​ 	  ​fun​ checkProductCodeExists checkAddressExists unvalidatedOrder ->
​ 	    result {
​ 	      ​let​! orderId =
​ 	        unvalidatedOrder.OrderId
​ 	        |> OrderId.create
​ 	        |> Result.mapError ValidationError
​ 	      ​let​! customerInfo =
​ 	        unvalidatedOrder.CustomerInfo
​ 	        |> toCustomerInfo
​ 	      ​let​! shippingAddress = ...
​ 	      ​let​! billingAddress  = ...
​ 	      ​let​! lines = ...
​ 	
​ 	      ​let​ validatedOrder : ValidatedOrder = {
​ 	        OrderId  = orderId
​ 	        CustomerInfo = customerInfo
​ 	        ShippingAddress = shippingAddress
​ 	        BillingAddress = billingAddress
​ 	        Lines = lines
​ 	      }
​ 	      ​return​ validatedOrder
​ 	    }

이전과 마찬가지로 Result.mapError를 사용하여 모든 오류 타입이 일치하는지 확인해야 합니다.

OrderId.create는 오류의 경우 문자열을 반환하므로 mapError를 사용하여 이를 ValidationError로 들어 올려야 합니다.

다른 헬퍼 함수는 단순 타입을 처리할 때 동일한 작업을 수행해야 합니다.

toCustomerInfo 및 toAddress 함수의 출력이 이미 ValidationError라고 가정하므로 Result.mapError를 사용하지 않습니다.

(즉 헬퍼 함수들은 Result를 반환해야함.

Working with Lists of Results

Result 타입을 사용하지 않고 주문 라인을 검증할 때 List.map을 사용하여 각 라인을 변환할 수 있었습니다.
​ 	​let​ validateOrder unvalidatedOrder =
​ 	  ...
​ 	
​ 	  ​// convert each line into an OrderLine domain type​
​ 	  ​let​ lines =
​ 	      unvalidatedOrder.Lines
​ 	      |> List.map (toValidatedOrderLine checkProductCodeExists)
​ 	
​ 	  ​// create and return a ValidatedOrder​
​ 	  ​let​ validatedOrder : ValidatedOrder = {
​ 	    ...
​ 	    Lines = lines
​ 	    ​// etc​
​ 	    }
​ 	  validatedOrder
그러나 이 접근 방식은 toValidatedOrderLine이 Result를 반환할 때 더 이상 작동하지 않습니다.
map을 사용한 후에는 ValidatedOrderLine list가 아니라 Result<ValidatedOrderLine,...> list으로 끝납니다.
ValidatedOrder.Lines의 값을 설정할 때 list of Result(Result[])가 아니라 Result of list(Result<validatedorderline,...>)가 필요합니다.
​ 	​let​ validateOrder unvalidatedOrder =
​ 	  ...
​ 	
​ 	  ​let​ lines = ​// lines is a "list of Result"​
​ 	      unvalidatedOrder.Lines
​ 	      |> List.map (toValidatedOrderLine checkProductCodeExists)
​ 	
​ 	  ​let​ validatedOrder : ValidatedOrder = {
​ 	    ...
​ 	    Lines = lines  ​// compiler error​
​ 	    ​//       ^ expecting a "Result of list" here​
​ 	    }
​ 	  ...
result 표현식을 사용하는 것은 여기서 도움이 되지 않습니다.
문제는 타입 불일치가 있다는 것입니다. 이제 질문은 이것입니다. list of Result을 목록의 Result of list로 어떻게 변환할 수 있습니까?
 
이를 수행하는 도우미 함수를 만들어 보겠습니다.
결과 목록을 순환하고, 결과 목록 중 하나라도 잘못된 경우 전체 결과는 오류가 됩니다.
모두 양호하면 전체 결과는 모든 성공의 리스트가 됩니다.
 
이를 구현하는 요령은 F#에서 표준 리스트 타입이 각 요소를 작은 리스트에 추가하여 만드는 링크드 리스트라는 점을 기억하는 것입니다.
(FP 세계에서 "cons" 연산자라고도 함 - :: )이 필요합니다. 구현은 간단합니다.
 
  • 두 매개변수가 모두 Ok이면 내용을 추가하고 결과 목록을 다시 Result로 래핑합니다.
  • 매개변수 중 하나가 오류이면 오류를 반환합니다.
​ 	​/// Prepend a Result<item> to a Result<list>​
​ 	​let​ prepend firstR restR =
​ 	  ​match​ firstR, restR ​with​
​ 	  | Ok first, Ok rest -> Ok (first::rest)
​ 	  | Error err1, Ok _ -> Error err1
​ 	  | Ok _, Error err2 -> Error err2
​ 	  | Error err1, Error _ -> Error err1
 
이 prepend 함수의 형식 서명을 보면 완전히 일반적이라는 것을 알 수 있습니다.
Result<'a>와 Result<'a list>를 가져와 새로운 Result<'a list>로 결합합니다.

마지막 항목부터 시작하여 목록을 반복하고(foldBack 사용)

각 Result 요소를 우리가 작성한 리스트 앞에 추가하여 Result<'a> 리스트에서 Result<'a list>를 만들 수 있습니다.

 우리는 이 함수를 sequence라 명칭하고 Result 모듈에 또 다른 유용한 함수로 추가할 것입니다. 구현은 다음과 같습니다.

​ 	​let​ sequence aListOfResults =
​ 	  ​let​ initialValue = Ok [] ​// empty list inside Result​
​ 	
​ 	  ​// loop through the list in reverse order,​
​ 	  ​// prepending each element to the initial value​
​ 	  List.foldBack prepend aListOfResults initialValue
이 코드가 어떻게 작동하는지 너무 걱정하지 마십시오. 언제 어떻게 사용해야 하는지만 알면 됩니다.
Result 유형을 정의한 다음(IntOrError라고 함) Sucess 목록으로 시퀀스를 테스트합니다.
​ 	​type​ IntOrError = Result<​int​,​string​>
​ 	
​ 	​let​ listOfSuccesses : IntOrError ​list​ = [Ok 1; Ok 2]
​ 	​let​ successResult =
​ 	  Result.sequence listOfSuccesses   ​// Ok [1; 2]
list of Result([Ok 1; Ok 2])가 (Ok [1; 2])가 포함된 Result로 변환된 것을 볼 수 있습니다.
실패 목록으로 시도해 보겠습니다.
	​let​ listOfErrors : IntOrError ​list​ = [ Error ​"bad"​; Error ​"terrible"​ ]
​ 	
​ 	​let​ errorResult =
​ 	  Result.sequence listOfErrors  ​// Error "bad"
Result를 얻었지만 이번에는 Error(Error "bad")가 포함되어 있습니다.
실패 예에서는 첫 번째 오류만 반환됩니다. 그러나 많은 경우 특히 유효성 검사를 수행할 때 모든 오류를 유지하려고 합니다.
이를 위한 함수형 프로그래밍 기법을 applicative 라고 합니다.
다음 섹션에서 간략하게 언급하겠지만 자세한 구현에 대해서는 다루지 않을 것입니다.
툴킷의 Result.sequence를 사용하여 마침내 ValidatedOrder를 구성하는 코드를 작성할 수 있습니다.
module ValidateOrderLines =
    open Lists

    type CheckProductCodeExists = DotDotDot
    type CheckAddressExists = DotDotDot
    type UnvalidatedOrder = {
        OrderId : DotDotDot
        CustomerInfo : DotDotDot
        ShippingAddress : DotDotDot
        BillingAddress : DotDotDot
        Lines : DotDotDot list
        }
    type ValidatedOrder = UnvalidatedOrder 
    module OrderId = 
        let create _ = dotDotDot()
    let toCustomerInfo _ = dotDotDot()
    let toAddress _ = dotDotDot()

    type ValidationError = ValidationError of string

    type ValidateOrder = 
      CheckProductCodeExists  // dependency
        -> CheckAddressExists // dependency
        -> UnvalidatedOrder   // input
        -> Result<ValidatedOrder,ValidationError>     // output

    let checkProductCodeExists _ = dotDotDot()
    let toValidatedOrderLine _ _ = dotDotDot()

    //>ValidateOrder_OrderLines
    let validateOrder : ValidateOrder = 
        fun checkProductCodeExists checkAddressExists unvalidatedOrder ->
            result {
                let! orderId = dotDotDot()
                let! customerInfo = dotDotDot()
                let! shippingAddress = dotDotDot()
                let! billingAddress  = dotDotDot()
                let! lines = 
                    unvalidatedOrder.Lines 
                    |> List.map (toValidatedOrderLine checkProductCodeExists) 
                    |> Result.sequence // convert list of Results to a single Result

                let validatedOrder : ValidatedOrder = {
                    OrderId  = orderId 
                    CustomerInfo = customerInfo 
                    ShippingAddress = shippingAddress 
                    BillingAddress = billingAddress  
                    Lines = lines 
                }
                return validatedOrder 
            }
    //<
List.map 다음에 Result.sequence를 일반적으로 traverse라고 하는 단일 함수로 결합하여 성능 효율적으로 만들 수 있습니다.
 
거의 끝났지만 마지막 문제가 하나 있습니다. validateOrder의 출력은 오류 케이스로 ValidationError 유형을 갖습니다.

 

그러나 기본 파이프라인에서는 오류 사례가 PlaceOrderError여야 합니다.

이제 placeOrder 함수에서 Result<ValidatedOrder,ValidationError> 타입을

Result<ValidatedOrder,PlaceOrderError> 타입으로 변환해야 합니다.
mapError를 사용하여 오류 값의 타입을 변환할 수 있습니다.
마찬가지로 priceOrder의 출력도 PricingError에서 PlaceOrderError로 변환해야 합니다.
module ValidateOrderLines2 =
    open ValidateOrderLines 

    type PlaceOrderEvent = DotDotDot
    type PlaceOrderError =
        | Validation of ValidationError 
        | Pricing of DotDotDot

    type PlaceOrder = 
        UnvalidatedOrder -> Result<PlaceOrderEvent list,PlaceOrderError>

    let checkProductExists _  = dotDotDot()
    let checkAddressExists _ = dotDotDot()
    let getProductPrice _ = dotDotDot()
    
    let validateOrder _ _ _ = dotDotDot()
    let priceOrder _ _ = dotDotDot()

    //>placeOrder 
    let placeOrder : PlaceOrder =       // definition of function
        fun unvalidatedOrder -> 
            result {
                let! validatedOrder = 
                    validateOrder checkProductExists checkAddressExists unvalidatedOrder 
                    |> Result.mapError PlaceOrderError.Validation
                let! pricedOrder = 
                    priceOrder getProductPrice validatedOrder 
                    |> Result.mapError PlaceOrderError.Pricing
                let acknowledgmentOption = dotDotDot()
                let events = dotDotDot()
                return events
            }
    //<
출력은 이제 우리가 원하는 대로 Result<ValidatedOrder,PlaceOrderError>입니다.












 

 

 

 

 

반응형