본문 바로가기

BackEnd

Serialization

반응형
이 책의 예제에서는 입력과 출력이 있는 함수로 워크플로를 설계했습니다.
여기서 입력은 커맨드, 출력은 이벤트입니다.
이러한 커맨드는 어디에서 왔습니까?
이벤트는 어디로 가나요?
메시지 큐, 웹 요청 등 바운드 컨텍스트 외부에 있는 일부 인프라에서 들어오거나 바운디드 컨텍스트 밖으로 나갑니다.
 
이 인프라는 특정 도메인을 이해하지 못하므로 도메인 모델의 유형을 JSON, XML 또는 protobuf와 같은 바이너리 형식과 같이 인프라가 이해할 수 있는 것으로 변환해야 합니다.
또한 주문의 현재 상태와 같이 워크플로에 필요한 내부 상태를 추적하는 방법이 필요합니다.
다시 말하지만, 우리는 아마도 데이터베이스와 같은 외부 서비스를 사용할 것입니다.

외부 인프라의 중요한 기능은 도메인 모델의 타입을 쉽게 직렬화 및 역직렬화할 수 있는 항목으로 변환하는 것입니다.
따라서 이 장에서는 이 작업을 수행하는 방법을 배웁니다.
직렬화할 수 있는 타입을 디자인하는 방법을 보고 도메인 개체를 이러한 중간 타입으로 변환하는 방법을 볼 것입니다.
 

Persistence vs. Serialization

몇 가지 정의부터 시작하겠습니다.

지속성(Persistence)은 단순히 그것을 만든 프로세스보다 오래 지속되는 상태를 의미한다고 말할 것입니다.

그리고 직렬화(Serialization)는 도메인별 표현을 바이너리, JSON 또는 XML과 같이 쉽게 지속할 수 있는 표현으로 변환하는 프로세스라고 말할 것입니다.

 

 

예를 들어 order-placing 워크플로 구현은 "order form arrived" 이벤트가 발생할 때마다 인스턴스화되고 실행됩니다.

그러나 코드 실행이 중지되면 비즈니스의 다른 부분에서 해당 데이터를 사용할 수 있도록 출력이 어떻게든 유지되기를 원합니다("지속"). "주변에 머물다"는 것이 반드시 적절한 데이터베이스에 저장된다는 의미는 아닙니다. 파일이나 대기열에 저장할 수 있습니다.

그리고 우리는 지속되는 데이터의 수명에 대해 가정해서는 안 됩니다.

단 몇 초 동안 유지될 수도 있고(예: 대기열에서) 또는 수십 년 동안 유지될 수도 있습니다(예: 데이터 웨어하우스).

 

이 장에서는 직렬화에 초점을 맞추고 다음 장에서는 지속성에 대해 살펴보겠습니다.

 

Designing for Serialization

​바운디드 컨텍스트 간 데이터 전송​에서 논의한 것처럼 choice 타입과 제약 조건에 대한 특수 타입이 깊이 중첩되어 있는 복잡한 도메인타입은 직렬화가 쉽지 않습니다.
따라서 고통 없는 직렬화의 비결은 도메인 개체를 직렬화를 위해 특별히 설계된 타입(데이터 전송 개체- DTO)으로 변환한 다음
도메인 타입 대신 해당 DTO를 직렬화하는 것입니다.

도메인 개체를 직렬화를 위해 특별히 설계된 타입(데이터 전송 개체- DTO)으로 변환한 다음 도메인 타입 대신 해당 DTO를 직렬화하는 것입니다.
역직렬화의 경우 다른 방향으로 동일한 작업을 수행합니다.

일반적으로 우리는 역직렬화가 가능한 한 깔끔하게 되기를 원합니다.
즉, 기본 데이터가 어떻게든 손상되지 않는 한 DTO로의 역직렬화는 항상 성공해야 합니다.
모든 종류의 도메인별 유효성 검사(예: OrderQty에 대한 정수 범위 유효성 검사 또는 ProductCode가 유효한지 확인)는 DTO-도메인 타입 변환 프로세스에서 수행되어야 하며, 여기서 우리는 오류 처리를 더 잘 제어할 수 있습니다.

Connecting the Serialization Code to the Workflow

직렬화 프로세스는 워크플로 파이프라인에 추가할 수 있는 또 다른 구성 요소입니다.
역직렬화 단계는 워크플로 앞에 추가되고 직렬화 단계는 워크플로 끝에 추가됩니다.
 
예를 들어 다음과 같은 워크플로가 있다고 가정합니다(지금은 오류 처리 및 Result 타입 무시).​

​ 	​type​ MyInputType = ...
​ 	​type​ MyOutputType = ...
​ 	
​ 	​type​ Workflow = MyInputType -> MyOutputType​

그런 다음 역직렬화 단계의 함수 서명은 다음과 같을 수 있습니다. (input - 역직렬화 to 바운디드컨텍스트)

​ 	​type​ JsonString = ​string​
​ 	​type​ MyInputDto = ...
​ 	
​ 	​type​ DeserializeInputDto = JsonString -> MyInputDto
​ 	​type​ InputDtoToDomain = MyInputDto -> MyInputType​
직렬화 단계는 다음과 같습니다. (output - 직렬화 to 인프라)
​ 	​type​ MyOutputDto = ...
​ 	
​ 	​type​ OutputDtoFromDomain = MyOutputType -> MyOutputDto
​ 	​type​ SerializeOutputDto = MyOutputDto -> JsonString
이러한 모든 함수는 다음과 같이 파이프라인에서 함께 연결될 수 있습니다.
​ 	​let​ workflowWithSerialization jsonString =
​ 	  jsonString
​ 	  |> deserializeInputDto   ​// JSON to DTO​
​ 	  |> inputDtoToDomain      ​// DTO to domain object​
​ 	  |> workflow              ​// the core workflow in the domain​
​ 	  |> outputDtoFromDomain   ​// Domain object to DTO​
​ 	  |> serializeOutputDto    ​// DTO to JSON​
​ 	  ​// final output is another JsonString
그리고 이 workflowWithSerialization 기능은 인프라에 노출되는 함수입니다.
입력 및 출력은 JsonString 또는 이와 유사하므로 인프라가 도메인에서 격리됩니다.
물론 실제로는 그렇게 간단하지 않습니다! 오류, 비동기 등을 처리해야 합니다. 그러나 이것은 기본 개념을 보여줍니다.

DTOs as a Contract Between Bounded Contexts

우리가 소비하는 커맨드는 다른 경계 컨텍스트의 출력에 의해 트리거되고 워크플로가 내보내는 이벤트는 다른 경계 컨텍스트에 대한 입력이 됩니다.
이러한 이벤트와 커맨드는 바운디드 컨텍스트가 지원해야 하는 일종의 계약을 형성합니다.
바운디드 컨텍스트 간의 긴밀한 결합을 피하고 싶기 때문에 확실히 느슨한 계약입니다.
그럼에도 불구하고 DTO인 이러한 이벤트 및 커맨드의 직렬화된 형식은 변경될 경우 주의해서 변경해야 합니다.
이것은 직렬화 형식을 항상 완벽하게 제어해야 하며 라이브러리가 자동으로 작업을 수행하도록 허용해서는 안 된다는 것을 의미합니다!

A Complete Serialization Example

JSON에서 도메인 개체를 직렬화 및 역직렬화하는 방법을 보여주기 위해 작은 예제를 만들어 보겠습니다.
다음과 같이 정의된 도메인 유형 Person을 유지하고 싶다고 가정해 보겠습니다.
​ 	​module​ Domain = ​// our domain-driven types​
​ 	
​ 	  ​/// constrained to be not null and at most 50 chars​
​ 	  ​type​ String50 = String50 ​of​ ​string​
​ 	
​ 	  ​/// constrained to be bigger than 1/1/1900 and less than today's date​
​ 	  ​type​ Birthdate = Birthdate ​of​ DateTime
​ 	
​ 	  ​/// Domain type​
​ 	  ​type​ Person = {
​ 	    First: String50
​ 	    Last: String50
​ 	    Birthdate : Birthdate
​ 	    }
String50 및 Birthdate 유형은 직접 직렬화할 수 없으므로
먼저 다음과 같이 모든 필드가 기본 형식인 해당 DTO 타입 Dto.Person(Dto 모듈의 Person)을 생성합니다.
​ 	​/// A module to group all the DTO-related​
​ 	​/// types and functions.​
​ 	​module​ Dto =
​ 	
​ 	  ​type​ Person = {
​ 	    First: ​string​
​ 	    Last: ​string​
​ 	    Birthdate : DateTime
​ 	    }
다음으로 "toDomain" 및 "fromDomain" 함수가 필요합니다.
도메인은 DTO에 대해 알지 못하기 때문에
이들은 도메인 유형이 아닌 DTO 유형과 연관되어 있으므로 Person이라는 하위 모듈의 Dto 모듈에도 넣도록 하겠습니다.
​ 	​module​ Dto =
​ 	
​ 	  ​module​ Person =
​ 	    ​let​ fromDomain (person:Domain.Person) :Dto.Person =
​ 	      ...
​ 	
​ 	    ​let​ toDomain (dto:Dto.Person) :Result<Domain.Person,​string​> =
​ 	      ...
앞에서 언급했듯이 모듈의 이름이 형식과 같은 경우 F# 4.1 이전 버전에선 CompilationRepresentation 특성을 사용해야 합니다.
한 쌍의 fromDomain 및 toDomain 함수를 갖는 이 패턴은 우리가 일관되게 사용할 것입니다.
 
도메인 유형을 DTO로 변환하는 fromDomain 함수부터 시작하겠습니다. (to 바깥)
복잡한 도메인 타입은 오류 없이 항상 DTO로 변환될 수 있기 때문에 이 함수는 항상 성공합니다(Result는 필요하지 않음).
​ 	​let​ fromDomain (person:Domain.Person) :Dto.Person =
​ 	  ​// get the primitive values from the domain object​
​ 	  ​let​ first = person.First |> String50.value
​ 	  ​let​ last = person.Last |> String50.value
​ 	  ​let​ birthdate = person.Birthdate |> Birthdate.value
​ 	
​ 	  ​// combine the components to create the DTO​
​ 	  {First = first; Last = last; Birthdate = birthdate}

 

toDomain 함수는 DTO를 도메인 타입으로 변환합니다.
다양한 유효성 검사 및 제약 조건이 실패할 수 있으므로 toDomain은 일반 Person이 아닌 Result<Person,string>을 반환합니다.
​ 	​let​ toDomain (dto:Dto.Person) :Result<Domain.Person,​string​> =
​ 	  result {
​ 	    ​// get each (validated) simple type from the DTO as a success or failure​
​ 	    ​let​! first = dto.First |> String50.create ​"First"​
​ 	    ​let​! last = dto.Last |> String50.create ​"Last"​
​ 	    ​let​! birthdate = dto.Birthdate |> Birthdate.create
​ 	
​ 	    ​// combine the components to create the domain object​
​ 	    ​return​ {
​ 	      First = first
​ 	      Last = last
​ 	      Birthdate = birthdate
​ 	    }
​ 	  }
String50 및 Birthdate와 같은 단순 유형은 create 메소드에서 Result를 리턴하기 때문에
오류 흐름을 처리하기 위해 result 계산 표현식을 사용하고 있습니다.
(ts같은 경우 Do notation 지원 라이브러리 활용 - 하나라도 위에서 오류 발생하면 맨 뒤로~)
 
예를 들어, ​단순 값의 무결성​에서 논의된 접근 방식을 사용하여 String50.create를 구현할 수 있습니다.
코드는 아래와 같습니다. 유용한 오류 메시지를 받을 수 있도록 필드 이름을 매개변수로 포함합니다.
​ 	​let​ create fieldName str : Result<String50,​string​> =
​ 	  ​if​ String.IsNullOrEmpty(str) ​then​
​ 	    Error (fieldName + ​" must be non-empty"​)
​ 	  ​elif​ str.Length > 50 ​then​
​ 	    Error (fieldName + ​" must be less that 50 chars"​)
​ 	  ​else​
​ 	    Ok (String50 str)

Wrapping the JSON Serializer

JSON 또는 XML을 직렬화에는 아마도 타사 라이브러리를 사용하는 것을 선호할 것입니다.
그러나 라이브러리의 API는 함수 친화적이지 않을 수 있으므로 직렬화 및 역직렬화 루틴을 래핑하여 파이프라인에서 사용하기에 적합하게 만들고 모든 예외를 Result로 변환할 수 있습니다.
다음은 표준 .NET JSON 직렬화 라이브러리(Newtonsoft.Json)의 일부를 래핑하는 방법입니다. 예를 들면 다음과 같습니다.
​ 	​module​ Json =
​ 	
​ 	  ​open​ Newtonsoft.Json
​ 	
​ 	  ​let​ serialize obj =
​ 	    JsonConvert.SerializeObject obj
​ 	
​ 	  ​let​ deserialize<​'​a> str =
​ 	    ​try​
​ 	      JsonConvert.DeserializeObject<​'​a> str
​ 	      |> Result.Ok
​ 	    ​with​
​ 	    ​// catch all exceptions and convert to Result​
​ 	    | ex -> Result.Error ex
직렬화 함수 Json.serialize 및 Json.deserialize를 호출할 수 있도록 적응된 버전을 넣을 자체 Json 모듈을 만들고 있습니다.

A Complete Serialization Pipeline

DTO-도메인 변환기와 직렬화 함수들을 사용하면 도메인 유형(Person 레코드)을 JSON 문자열로 가져올 수 있습니다.
​ 	​/// Serialize a Person into a JSON string​
​ 	​let​ jsonFromDomain (person:Domain.Person) =
​ 	  person
​ 	  |> Dto.Person.fromDomain
​ 	  |> Json.serialize
테스트하면 예상한 JSON 문자열을 얻습니다.
​ 	​// input to test with​
​ 	​let​ person : Domain.Person = {
​ 	  First = String50 ​"Alex"​
​ 	  Last = String50 ​"Adams"​
​ 	  Birthdate = Birthdate (DateTime(1980,1,1))
​ 	  }
​ 	
​ 	​// use the serialization pipeline​
​ 	jsonFromDomain person
​ 	
​ 	​// The output is​
​ 	​// "{"First":"Alex","Last":"Adams","Birthdate":"1980-01-01T00:00:00"}"
직렬화 파이프라인을 구성하는 것은 모든 단계가 Result-free이기 때문에 간단하지만
역직렬화 파이프라인을 구성하는 것dms Json.deserialize와 PersonDto.fromDomain이 모두 Result를 반환할 수 있기 때문에 더 까다롭습니다.
해결책은 ​Common Error Type​으로 변환​에서 배운 것처럼 Result.mapError를 사용하여 잠재적 실패를 공통 초이스 타입으로 변환한 다음, 결과 표현식을 사용하여 오류를 숨기는 것입니다.
​ 	​type​ DtoError =
​ 	  | ValidationError ​of​ ​string​
​ 	  | DeserializationException ​of​ exn
​ 	
​ 	​/// Deserialize a JSON string into a Person​
​ 	​let​ jsonToDomain jsonString :Result<Domain.Person,DtoError> =
​ 	  result {
​ 	    ​let​! deserializedValue =
​ 	      jsonString
​ 	      |> Json.deserialize
​ 	      |> Result.mapError DeserializationException
​ 	
​ 	    ​let​! domainValue =
​ 	      deserializedValue
​ 	      |> Dto.Person.toDomain
​ 	      |> Result.mapError ValidationError
​ 	
​ 	    ​return​ domainValue
​ 	    }​
오류가 없는 입력으로 테스트해 보겠습니다.
​ 	​// JSON string to test with​
​ 	​let​ jsonPerson = ​"""{​
​ 	​  "​First​": "​Alex​",​
​ 	​  "​Last​": "​Adams​",​
​ 	​  "​Birthdate​": "​1980-01-01T00:00:00​"​
​ 	​  }"""​
​ 	
​ 	​// call the deserialization pipeline​
​ 	jsonToDomain jsonPerson |> printfn ​"%A"

​ 	​// The output is:​
​ 	​//  Ok {First = String50 "Alex";​
​ 	​//      Last = String50 "Adams";​
​ 	​//      Birthdate = Birthdate 01/01/1980 00:00:00;}
전체 결과가 Ok이고 Person 도메인 개체가 성공적으로 생성되었음을 알 수 있습니다.
이제 JSON 문자열을 수정하여 이름이 비어 있고 날짜가 잘못되어 오류가 발생하도록 하고 코드를 다시 실행해 보겠습니다.
​ 	​let​ jsonPersonWithErrors = ​"""{​
​ 	​  "​First​": "",​
​ 	​  "​Last​": "​Adams​",​
​ 	​  "​Birthdate​": "​1776-01-01T00:00:00​"​
​ 	​  }"""​
​ 	
​ 	​// call the deserialization pipeline​
​ 	jsonToDomain jsonPersonWithErrors |> printfn ​"%A"​
​ 	
​ 	​// The output is:​
​ 	​//  Error (ValidationError [​
​ 	​//        "First must be non-empty"​
​ 	​//        ])
실제로 Result의 Error 경우와 유효성 검사 오류 메시지 중 하나가 표시되는 것을 볼 수 있습니다.
실제 응용 프로그램에서는 이것을 리스트로 기록하고 호출자에게 오류를 반환할 수 있습니다.
(이 구현에서는 첫 번째 오류만 반환합니다. 모든 오류를 반환하려면 ​applicative functor를 사용해야 함.)
 
역직렬화 중 오류 처리에 대한 또 다른 접근 방식은 역직렬화 코드에서 예외를 throw하도록 하는 것입니다.
역직렬화 오류를 예상한 상황으로 처리할지 아니면 전체 파이프라인을 충돌시키는 "패닉"으로 처리할지에 따라 다릅니다.
그리고 그것은 API의 공개 정도, 호출자를 얼마나 신뢰하는지, 이러한 종류의 오류에 대해 호출자에게 제공하려는 정보의 양에 따라 달라집니다.
(보통 역직렬화는 인입 전에 일어나므로 이런식으로 처리해도 딱히 문제 없음)

Working with Other Serializers

위의 코드는 Newtonsoft.Json 직렬 변환기를 사용합니다.
다른 직렬 변환기를 사용할 수 있지만 PersonDto 타입에 속성을 추가해야 할 수도 있습니다.
예를 들어 DataContractSerializer(XML용) 또는 DataContractJsonSerializer(JSON용)를 사용하여 레코드 타입을 직렬화하려면 DataContractAttribute 및 DataMemberAttribute로 DTO 유형을 장식해야 합니다.
​ 	​module​ Dto =
​ 	  [<DataContract>]
​ 	  ​type​ Person = {
​ 	    [<field: DataMember>]
​ 	    First: ​string​
​ 	    [<field: DataMember>]
​ 	    Last: ​string​
​ 	    [<field: DataMember>]
​ 	    Birthdate : DateTime
​ 	    }​
이것은 직렬화 유형을 도메인 타입과 별도로 유지하는 다른 이점 중 하나를 보여줍니다.
즉, 도메인 타입이 이와 같은 복잡한 속성으로 오염되지 않습니다.
항상 그렇듯이 도메인 문제를 인프라 문제와 분리하는 것이 좋습니다.
직렬 변환기에 대해 알아야 할 또 다른 유용한 속성은 매개 변수가 없는(숨겨진) 생성자를 내보내는 CLIMutableAttribute이며,
종종 리플렉션을 사용하는 직렬 변환기에 필요합니다.
마지막으로, 다른 F# 컴포넌트로만 작업할 예정이라면 FsPickler 또는 Chiron과 같은 F# 전용 직렬 변환기를 사용할 수 있습니다.
이 작업을 수행하면 이제 모두 동일한 프로그래밍 언어를 사용해야 한다는 점에서 바운디드 컨텍스트 간에 결합이 도입됩니다.

Working with Multiple Versions of a Serialized Type

시간이 지남에 따라, 디자인이 발전함에 따라 필드가 추가되거나 제거되거나 이름이 바뀌면서 도메인 타입을 변경해야 할 수 있습니다.
이것은 차례로 DTO 유형에도 영향을 줄 수 있습니다.
DTO 유형은 바운디드 컨텍스트 간의 계약 역할을 하며 이 계약을 위반하지 않는 것이 중요합니다.
이는 시간이 지남에 따라 여러 버전의 DTO 유형을 지원해야 할 수 있음을 의미합니다.
Greg Young의 저서인 Versioning in the Event Sourced System에는 사용 가능한 다양한 접근 방식에 대한 좋은 설명이 있습니다.

 

How to Translate Domain Types to DTOs

우리가 정의하는 도메인 유형은 매우 복잡할 수 있지만 해당 DTO 타입은 primitive 타입만 포함하는 단순한 구조여야 합니다.
그러면 특정 도메인 타입이 지정된 DTO를 어떻게 설계합니까? 몇 가지 지침을 살펴보겠습니다.

Single-Case Unions

단일 케이스 공용체(이 책에서 "단순 유형"이라고 함)는 DTO의 기본 프리미티브로 나타낼 수 있습니다.
예를 들어 ProductCode가 이 도메인 유형인 경우:
​type​ ProductCode = ProductCode ​of​ ​string​
그러면 해당 DTO 유형은 문자열입니다.

Options

옵션의 경우 None 케이스를 null로 바꿀 수 있습니다. 옵션이 참조 유형을 래핑하는 경우 null이 유효한 값이기 때문에 아무 것도 할 필요가 없습니다. int와 같은 값 유형의 경우 Nullable<int>와 같은 nullable에 해당하는 값을 사용해야 합니다.

Records

레코드로 정의된 도메인 유형은 각 필드의 유형이 해당 DTO로 변환되는 한 DTO의 레코드로 남을 수 있습니다.
다음은 단일 케이스 공용체, 선택적 값 및 레코드 타입을 보여주는 예입니다.
​ 	​/// Domain types​
​ 	​type​ OrderLineId = OrderLineId ​of​ ​int​
​ 	​type​ OrderLineQty = OrderLineQty ​of​ ​int​
​ 	​type​ OrderLine = {
​ 	  OrderLineId : OrderLineId
​ 	  ProductCode : ProductCode
​ 	  Quantity : OrderLineQty option
​ 	  Description : ​string​ option
​ 	  }
​ 	
​ 	​/// Corresponding DTO type​
​ 	​type​ OrderLineDto = {
​ 	  OrderLineId : ​int​
​ 	  ProductCode : ​string​
​ 	  Quantity : Nullable<​int​>
​ 	  Description : ​string​
​ 	  }

Collections

리스트, 시퀀스 및 집합은 모든 직렬화 형식에서 지원되는 Array로 변환되어야 합니다.
​ 	​type​ Order = {
​ 	  ...
​ 	  Lines : OrderLine ​list​
​ 	  }
​ 	
​ 	​/// Corresponding DTO type​
​ 	​type​ OrderDto = {
​ 	  ...
​ 	  Lines : OrderLineDto[]
​ 	  }
map 및 기타 복잡한 컬렉션의 경우 취하는 접근 방식은 직렬화 형식에 따라 다릅니다.
JSON 형식을 사용할 때 JSON 개체는 키-값 컬렉션일 뿐이므로 맵에서 JSON 개체로 직접 직렬화할 수 있어야 합니다.
다른 형식의 경우 특수 표현을 만들어야 할 수도 있습니다.
예를 들어 맵은 DTO에서 레코드 배열로 표시될 수 있습니다. 여기서 각 레코드는 키-값 쌍입니다.
​ 	​/// Domain type​
​ 	​type​ Price = Price ​of​ decimal
​ 	​type​ PriceLookup = Map<ProductCode,Price>
​ 	
​ 	​/// DTO type to represent a map​
​ 	​type​ PriceLookupPair = {
​ 	  Key : ​string​
​ 	  Value : decimal
​ 	  }
​ 	​type​ PriceLookupDto = {
​ 	  KVPairs : PriceLookupPair []
​ 	  }​

 

또는 맵을 직렬화 해제 시 함께 zip할 수 있는 두 개의 병렬 배열로 나타낼 수 있습니다.
​ 	​/// Alternative DTO type to represent a map​
​ 	​type​ PriceLookupDto = {
​ 	  Keys : ​string​ []
​ 	  Values : decimal []
​ 	  }

Discriminated Unions Used as Enumerations

자주, 모든 경우가 추가 데이터가 없는 케이스가 이름인 공용체를 사용합니다.
이것들은 직렬화될 때 일반적으로 정수로 표시되는 .NET 열거형으로 나타낼 수 있습니다.
​ 	​/// Domain type​
​ 	​type​ Color =
​ 	  | Red
​ 	  | Green
​ 	  | Blue
​ 	
​ 	​/// Corresponding DTO type​
​ 	​type​ ColorDto =
​ 	  | Red = 1
​ 	  | Green = 2
​ 	  | Blue = 3
역직렬화할 때 .NET 열거형 값이 열거된 값 중 하나가 아닌 경우를 처리해야 합니다.
​ 	​let​ toDomain dto : Result<Color,_> =
​ 	  ​match​ dto ​with​
​ 	  | ColorDto.Red -> Ok Color.Red
​ 	  | ColorDto.Green -> Ok Color.Green
​ 	  | ColorDto.Blue -> Ok Color.Blue
​ 	  | _ -> Error (sprintf ​"Color %O is not one of Red,Green,Blue"​ dto)
또는 케이스 이름을 값으로 사용하여 열거형 스타일 공용체를 문자열로 직렬화할 수 있습니다.
그러나 이것은 이름 바꾸기 문제에 더 민감합니다.

Tuples

튜플은 도메인에서 자주 발생하지 않아야 하지만
발생하는 경우 튜플이 대부분의 직렬화 형식에서 지원되지 않기 때문에 특별히 정의된 레코드로 표시해야 할 수 있습니다.
아래 예에서 도메인 유형 Card는 튜플이지만 해당 CardDto 유형은 레코드입니다.
​ 	​/// Components of tuple​
​ 	​type​ Suit = Heart | Spade | Diamond | Club
​ 	​type​ Rank = Ace | Two | Queen | King ​// incomplete for clarity​
​ 	
​ 	​// Tuple​
​ 	​type​ Card = Suit * Rank
​ 	
​ 	​/// Corresponding DTO types​
​ 	​type​ SuitDto = Heart = 1 | Spade = 2 | Diamond = 3 | Club = 4
​ 	​type​ RankDto = Ace = 1 | Two = 2 | Queen = 12 | King = 13
​ 	​type​ CardDto = {
​ 	  Suit : SuitDto
​ 	  Rank : RankDto
​ 	  }​

Choice Types

초이스 타입은 사용되는 선택 항목을 나타내는 "태그"와 해당 사례와 관련된 데이터가 포함된 가능한 각 사례에 대한 필드가 있는 레코드로 나타낼 수 있습니다.
특정 케이스가 DTO에서 변환되면 해당 케이스의 필드에는 데이터가 포함되고 다른 모든 필드는 다른 케이스의 경우 null이 됩니다(또는 목록의 경우 비어 있음).
일부 직렬 변환기는 F#의 discriminated union type을 직접 처리할 수 있지만 그들이 사용하는 포맷을 제어할 수는 없습니다.
다른 직렬 변환기를 사용하는 다른 경계 컨텍스트가 이를 해석하는 방법을 모르는 경우 문제가 될 수 있습니다.
DTO는 계약의 일부이므로 형식을 명시적으로 제어하는 ​​것이 좋습니다.
다음은 네 가지 선택 항목이 있는 도메인 유형(예제)의 예입니다.
  • A로 태그가 지정된 빈 케이스
  • B로 태그가 지정된 정수
  • C로 태그가 지정된 문자열 리스트
  • D로 태그가 지정된 이름(위의 이름 유형 사용)
​ 	​/// Domain types​
​ 	​type​ Name = {
​ 	  First : String50
​ 	  Last : String50
​ 	  }
​ 	
​ 	​type​ Example =
​ 	  | A
​ 	  | B ​of​ ​int​
​ 	  | C ​of​ ​string​ ​list​
​ 	  | D ​of​ Name
그리고 각 케이스의 유형이 직렬화 가능한 버전으로 대체된 해당 DTO 유형의 모양은 다음과 같습니다.
int는 Nullable<int>, string list는 string[], Name은 NameDto입니다.
​ 	​/// Corresponding DTO types​
​ 	​type​ NameDto = {
​ 	  First : ​string​
​ 	  Last : ​string​
​ 	  }
​ 	
​ 	​type​ ExampleDto = {
​ 	  Tag : ​string​ ​// one of "A","B", "C", "D"​
​ 	  ​// no data for A case​
​ 	  BData : Nullable<​int​>  ​// data for B case​
​ 	  CData : ​string​[]       ​// data for C case​
​ 	  DData : NameDto        ​// data for D case​
​ 	  }
직렬화는 간단합니다. 선택한 케이스에 대한 적절한 데이터를 변환하고 다른 모든 케이스에 대한 데이터를 null로 설정하기만 하면 됩니다.
 	​let​ nameDtoFromDomain (name:Name) :NameDto =
​ 	    ​let​ first = name.First |> String50.value
​ 	    ​let​ last = name.Last |> String50.value
​ 	    {First=first; Last=last}
​ 	
​ 	​let​ fromDomain (domainObj:Example) :ExampleDto =
​ 	  ​let​ nullBData = Nullable()
​ 	  ​let​ nullCData = ​null​
​ 	  ​let​ nullDData = Unchecked.defaultof<NameDto>
​ 	  ​match​ domainObj ​with​
​ 	  | A ->
​ 	      {Tag=​"A"​; BData=nullBData; CData=nullCData; DData=nullDData}
​ 	  | B i ->
​ 	      ​let​ bdata = Nullable i
​ 	      {Tag=​"B"​; BData=bdata; CData=nullCData; DData=nullDData}
​ 	  | C strList ->
​ 	      ​let​ cdata = strList |> List.toArray
​ 	      {Tag=​"C"​; BData=nullBData; CData=cdata; DData=nullDData}
​ 	  | D name ->
​ 	      ​let​ ddata = name |> nameDtoFromDomain
​ 	      {Tag=​"D"​; BData=nullBData; CData=nullCData; DData=ddata}
이 코드에서 진행 중인 작업은 다음과 같습니다.
  • 함수 상단에서 각 필드에 대해 null 값을 설정한 다음 일치하는 케이스와 관련이 없는 필드에 할당합니다.
  • "B"의 경우 Nullable<_> 유형에 null을 직접 할당할 수 없습니다. 대신 Nullable() 함수를 사용해야 합니다.
  • "C"의 경우 Array는 .NET 클래스이기 때문에 null을 할당할 수 있습니다.
  • "D"의 경우 NameDto와 같은 F# 레코드에도 null을 할당할 수 없으므로 "백도어" 함수 Unchecked.defaultOf<_>를 사용하여 null 값을 만듭니다. Interop 또는 직렬화를 위해 null을 생성해야 하는 경우에만 일반 코드에서 사용해서는 안 됩니다.
이와 같은 태그로 선택 유형을 역직렬화할 때 "태그" 필드에 일치시킨 다음 각 경우를 개별적으로 처리합니다.
 
역직렬화를 시도하기 전에 태그와 연결된 데이터가 null이 아닌지 항상 확인해야 합니다.
​ 	​let​ nameDtoToDomain (nameDto:NameDto) :Result<Name,​string​> =
​ 	    result {
​ 	      ​let​! first = nameDto.First |> String50.create
​ 	      ​let​! last = nameDto.Last |> String50.create
​ 	      ​return​ {First=first; Last=last}
​ 	    }
​ 	
​ 	​let​ toDomain dto : Result<Example,​string​> =
​ 	  ​match​ dto.Tag ​with​
​ 	    | ​"A"​ ->
​ 	      Ok A
​ 	    | ​"B"​ ->
​ 	      ​if​ dto.BData.HasValue ​then​
​ 	        dto.BData.Value |> B |> Ok
​ 	      ​else​
​ 	        Error ​"B data not expected to be null"​
​ 	    | ​"C"​ ->
​ 	      ​match​ dto.CData ​with​
​ 	      | ​null​ ->
​ 	        Error ​"C data not expected to be null"​
​ 	      | _ ->
​ 	        dto.CData |> Array.toList |> C |> Ok
​ 	    | ​"D"​ ->
​ 	      ​match​ box dto.DData ​with​
​ 	      | ​null​ ->
​ 	        Error ​"D data not expected to be null"​
​ 	      | _ ->
​ 	        dto.DData
​ 	        |> nameDtoToDomain  ​// returns Result...​
​ 	        |> Result.map D     ​// ...so must use "map"​
​ 	    | _ ->
​ 	      ​// all other cases​
​ 	      ​let​ msg = sprintf ​"Tag '%s' not recognized"​ dto.Tag
​ 	      Error msg​
"B" 및 "C"의 경우 primitive 값에서 도메인 값으로의 변환은 오류가 없습니다(데이터가 null이 아님을 확인한 후).
"D"의 경우 NameDto에서 Name으로의 변환이 실패할 수 있으므로 D 케이스 생성자와 매핑해야 하는 결과를 반환합니다(Result.map 사용).

Serializing Records and Choice Types Using Maps

복합 유형(레코드 및 식별된 공용체)에 대한 대체 직렬화 접근 방식은 모든 것을 키-값 맵으로 직렬화하는 것입니다.
즉, 모든 DTO는 .NET 유형 IDictionary<string,obj>와 같은 방식으로 구현됩니다.
이 접근 방식은 JSON 개체 모델과 잘 일치하는 JSON 형식 작업에 특히 적용할 수 있습니다.
 
이 접근 방식의 장점은 DTO 구조에 암시적인 "계약"이 없다는 것입니다.
키-값 맵은 무엇이든 포함할 수 있으므로 고도로 분리된 상호 작용을 촉진합니다.
단점은 contract가 전혀 없다는 것!
즉, 생산자와 소비자 사이에 기대치의 불일치가 있을 때 알기 어렵습니다.
때로는 약간의 결합이 유용할 수 있습니다.
 
몇 가지 코드를 살펴보겠습니다. 이 접근 방식을 사용하여 다음과 같이 Name 레코드를 직렬화합니다.
​ 	​let​ nameDtoFromDomain (name:Name) :IDictionary<​string​,obj> =
​ 	  ​let​ first = name.First |> String50.value :> obj
​ 	  ​let​ last = name.Last |> String50.value :> obj
​ 	  [
​ 	    (​"First"​,first)
​ 	    (​"Last"​,last)
​ 	  ] |> dict
여기서 우리는 키/값 쌍의 목록을 만든 다음 내장 함수 dict를 사용하여 이들로부터 IDictionary를 빌드합니다.
그런 다음 이 사전이 JSON으로 직렬화되면 출력은 마치 별도의 NameDto 유형을 만들고 직렬화한 것처럼 보입니다.
한 가지 주의할 점은 IDictionary가 obj를 값 타입으로 사용한다는 것입니다.
즉, 레코드의 모든 값은 업캐스트 연산자 :>를 사용하여 명시적으로 obj로 캐스트되어야 합니다.
 
초이스 타입의 경우 반환되는 사전에는 정확히 하나의 항목이 있지만 키 값은 선택 항목에 따라 다릅니다.
예를 들어 Example 유형을 직렬화하는 경우 키는 "A", "B", "C" 또는 "D" 중 하나가 됩니다.
​ 	​let​ fromDomain (domainObj:Example) :IDictionary<​string​,obj> =
​ 	  ​match​ domainObj ​with​
​ 	  | A ->
​ 	    [ (​"A"​,​null​) ] |> dict
​ 	  | B i ->
​ 	    ​let​ bdata = Nullable i :> obj
​ 	    [ (​"B"​,bdata) ] |> dict
​ 	  | C strList ->
​ 	    ​let​ cdata = strList |> List.toArray :> obj
​ 	    [ (​"C"​,cdata) ] |> dict
​ 	  | D name ->
​ 	    ​let​ ddata = name |> nameDtoFromDomain :> obj
​ 	    [ (​"D"​,ddata) ] |> dict
위의 코드는 nameDtoFromDomain에 대한 유사한 접근 방식을 보여줍니다.
각 경우에 대해 데이터를 직렬화 가능한 형식으로 변환한 다음 obj로 캐스트합니다.
데이터가 이름인 "D"의 경우 직렬화 가능 형식은 또 다른 IDictionary입니다.
역직렬화는 조금 더 까다롭습니다.
각 필드에 대해 우리는
(a) 딕셔너리에서 그것이 존재하는지 확인하고, 
(b) 존재한다면 그것을 검색하고 올바른 타입으로 변환을 시도해야 합니다.
 
getValue라고 부를 도우미 함수를 호출합니다.
​ 	​let​ getValue key (dict:IDictionary<​string​,obj>) :Result<​'​a,​string​> =
​ 	  ​match​ dict.TryGetValue key ​with​
​ 	  | (true,value) ->  ​// key found!​
​ 	    ​try​
​ 	      ​// downcast to the type 'a and return Ok​
​ 	      (value :?> ​'​a) |> Ok
​ 	    ​with​
​ 	    | :? InvalidCastException ->
​ 	      ​// the cast failed​
​ 	      ​let​ typeName = typeof<​'​a>.Name
​ 	    ​let​ msg = sprintf ​"Value could not be cast to %s"​ typeName
​ 	    Error msg
​ 	| (false,_) ->     ​// key not found​
​ 	  ​let​ msg = sprintf ​"Key '%s' not found"​ key
​ 	  Error msg​
그러면 Name을 역직렬화하는 방법을 살펴보겠습니다.
 
먼저 "First" 키에서 값을 가져와야 합니다(오류가 발생할 수 있음).
작동하는 경우 String50.create를 호출하여 First 필드를 가져옵니다(오류가 발생할 수도 있음).
"Last" 키와 Last 필드도 유사합니다. 항상 그렇듯이 결과 표현식을 사용하여 삶을 더 쉽게 만들 것입니다.
​ 	​let​ nameDtoToDomain (nameDto:IDictionary<​string​,obj>) :Result<Name,​string​> =
​ 	  result {
​ 	    ​let​! firstStr = nameDto |> getValue ​"First"​
​ 	    ​let​! first = firstStr |> String50.create
​ 	    ​let​! lastStr = nameDto |> getValue ​"Last"​
​ 	    ​let​! last = lastStr |> String50.create
​ 	    ​return​ {First=first; Last=last}
​ 	  }​
 
Example과 같은 초이스 타입을 역직렬화하려면 각 경우에 대해 키가 있는지 여부를 테스트해야 합니다.
 
만약 있다면 그것을 검색하고 도메인 객체로 변환하려고 시도할 수 있습니다.
다시 말하지만 오류 가능성이 매우 높기 때문에 각 경우에 대해 결과 표현식을 사용합니다.
​ 	​let​ toDomain (dto:IDictionary<​string​,obj>) : Result<Example,​string​> =
​ 	  ​if​ dto.ContainsKey ​"A"​ ​then​
​ 	    Ok A    ​// no extra data needed​
​ 	  ​elif​ dto.ContainsKey ​"B"​ ​then​
​ 	    result {
​ 	      ​let​! bData = dto |> getValue ​"B"​ ​// might fail​
​ 	      ​return​ B bData
​ 	      }
​ 	  ​elif​ dto.ContainsKey ​"C"​ ​then​
​ 	    result {
​ 	      ​let​! cData = dto |> getValue ​"C"​ ​// might fail​
​ 	      ​return​ cData |> Array.toList |> C
​ 	      }
​ 	  ​elif​ dto.ContainsKey ​"D"​ ​then​
​ 	    result {
​ 	      ​let​! dData = dto |> getValue ​"D"​ ​// might fail​
​ 	      ​let​! name = dData |> nameDtoToDomain  ​// might also fail​
​ 	      ​return​ name |> D
​ 	      }
​ 	  ​else​
​ 	    ​// all other cases​
​ 	    ​let​ msg = sprintf ​"No union case recognized"​
​ 	    Error msg

 

Generics

대부분의 경우 도메인 타입은 제네릭입니다.
직렬화 라이브러리가 제네릭을 지원하는 경우 제네릭을 사용하여 DTO도 만들 수 있습니다.
예를 들어 Result 유형은 제네릭 타입이며 다음과 같이 제네릭 ResultDto로 변환할 수 있습니다.
​ 	​type​ ResultDto<​'​OkData,​'​ErrorData ​when​ ​'​OkData : ​null​ ​and​ ​'​ErrorData: ​null​> = {
​ 	  IsError : ​bool​  ​// replaces "Tag" field​
​ 	  OkData : ​'​OkData
​ 	  ErrorData : ​'​ErrorData
​ 	  }​
일반 유형 'OkData 및 'ErrorData는 연결된 JSON 개체에서 누락되거나 null일 수 있으므로 nullable로 제한되어야 합니다.
직렬화 라이브러리가 제네릭을 지원하지 않는 경우 각 구체적인 사례에 대해 특수 타입을 생성해야 합니다.
실제로 직렬화해야 하는 제네릭 타입은 거의 없다는 것을 알게 될 것입니다.
예를 들어 다음은 제네릭 타입이 아닌 구체적인 타입을 사용하여 DTO로 변환된 주문 배치 워크플로의 Result type입니다.
​ 	​type​ PlaceOrderResultDto = {
​ 	  IsError : ​bool​
​ 	  OkData : PlaceOrderEventDto[]
​ 	  ErrorData : PlaceOrderErrorDto
​ 	  }

 

반응형