형식 제공자 만들기

형식 제공자 만들기 (Tutorial: Create a Type Provider)

F#의 형식 제공자(Type Provider) 메커니즘은 정보가 풍부한 프로그래밍(information rich programming)을 지원하는 핵심 장치예요. 이 튜토리얼에서는 몇 가지 간단한 형식 제공자를 직접 만들어 보면서 기본 개념을 익히는 방식으로, 여러분이 나만의 형식 제공자를 만드는 방법을 설명해 드릴게요. 형식 제공자 메커니즘 자체에 대한 더 자세한 내용은 Type Providers 문서를 참고하시면 돼요.

F# 생태계에는 흔히 쓰이는 인터넷·엔터프라이즈 데이터 서비스용 형식 제공자가 여럿 있어요. 예를 들면 이런 것들이죠:

  • FSharp.Data: JSON, XML, CSV, HTML 문서 형식용 형식 제공자를 포함해요.
  • SwaggerProvider: OpenApi 3.0과 Swagger 2.0 스키마로 기술된 API용으로, 객체 모델과 HTTP 클라이언트를 생성하는 생성형(generative) 형식 제공자 두 개를 담고 있어요.
  • FSharp.Data.SqlClient: 컴파일 타임에 검사되는 T-SQL 내장(embedding)을 F#에 제공하는 형식 제공자 모음이에요.

여러분은 직접 형식 제공자를 만들 수도 있고, 다른 사람이 만든 형식 제공자를 참조해서 쓸 수도 있어요. 예를 들어 회사에 방대하고 계속 늘어나는 이름 붙은 데이터셋을 제공하는 데이터 서비스가 있다고 해 볼까요. 각 데이터셋은 저마다 안정적인 데이터 스키마를 갖고 있어요. 이때 스키마를 읽고 현재 데이터셋을 강한 형식(strongly typed)으로 프로그래머에게 보여 주는 형식 제공자를 만들 수 있답니다.

출처: 공식문서 - Tutorial: Create a Type Provider

본문

시작하기 전에 (Before You Start)

형식 제공자 메커니즘은 주로 안정적인 데이터·서비스 정보 공간을 F# 프로그래밍 경험에 주입하는 데 설계됐어요. 반대로 이런 용도에는 쓰지 않는 게 좋아요:

  • 프로그램 실행 중에, 그리고 프로그램 로직에 영향을 주는 방식으로 스키마가 바뀌는 정보 공간을 주입하는 것.
  • 언어 내부(intra-language) 메타 프로그래밍 — 이 영역에도 쓸 만한 경우는 있지만, 형식 제공자는 이 용도로 설계된 게 아니에요.

그러니 형식 제공자는 꼭 필요하고, 만들었을 때 가치가 매우 높은 경우에만 사용하는 게 원칙이에요. 스키마가 없는 곳에서 형식 제공자를 쓰거나, 평범한(또는 이미 존재하는) .NET 라이브러리로 충분한 상황에서 형식 제공자를 쓰는 것은 피하세요.

시작하기 전에 스스로에게 물어보면 좋은 질문들이 있어요:

  • 정보 소스의 스키마가 있어요? 있다면 F#·.NET 형식 시스템으로의 매핑은 어떻게 하면 될까요?
  • 구현의 출발점으로 기존의 (동적 타입) API를 활용할 수 있나요?
  • 여러분과 회사가 그 형식 제공자를 쓸 일이 충분히 많아서 만들 가치가 있을까요? 평범한 .NET 라이브러리로는 부족한가요?
  • 스키마가 얼마나 자주 바뀌나요?
    • 코딩하는 동안 바뀌나요?
    • 코딩 세션 사이에 바뀌나요?
    • 프로그램 실행 중에 바뀌나요?

형식 제공자는 런타임에, 그리고 컴파일된 코드의 수명 동안 스키마가 안정적인 상황에 가장 잘 맞아요.

간단한 형식 제공자 (A Simple Type Provider)

이 샘플은 F# Type Provider SDK의 examples 디렉터리에 있는 샘플과 비슷한 Samples.HelloWorldTypeProvider예요. 이 제공자는 아래 코드처럼 F# 서명(signature) 문법을 써서, Type1을 제외한 나머지 세부 사항은 생략한 채, **소거된(erased) 형식 100개가 들어 있는 "형식 공간"**을 만들어 줘요. 소거된 형식에 대한 자세한 내용은 이 문서의 뒷부분 "Details About Erased Provided Types"(소거된 제공 형식의 이해) 절을 참고하세요.

namespace Samples.HelloWorldTypeProvider

type Type1 =
    /// This is a static property.
    static member StaticProperty : string

    /// This constructor takes no arguments.
    new : unit -> Type1

    /// This constructor takes one argument.
    new : data:string -> Type1

    /// This is an instance property.
    member InstanceProperty : int

    /// This is an instance method.
    member InstanceMethod : x:int -> char

    nested type NestedType =
        /// This is StaticProperty1 on NestedType.
        static member StaticProperty1 : string
        …
        /// This is StaticProperty100 on NestedType.
        static member StaticProperty100 : string

type Type2 =
…
…

type Type100 =
…

여기서 제공되는 형식·멤버의 집합은 정적으로 알려져 있어요. 즉 이 예시는 제공자가 스키마에 의존하는 형식을 제공하는 능력은 활용하지 않습니다. 형식 제공자 구현의 개요는 아래 코드에 나와 있고, 자세한 내용은 이 문서의 뒷부분에서 다룰게요.

namespace Samples.FSharp.HelloWorldTypeProvider

open System
open System.Reflection
open ProviderImplementation.ProvidedTypes
open FSharp.Core.CompilerServices
open FSharp.Quotations

// This type defines the type provider. When compiled to a DLL, it can be added
// as a reference to an F# command-line compilation, script, or project.
[<TypeProvider>]
type SampleTypeProvider(config: TypeProviderConfig) as this =

  // Inheriting from this type provides implementations of ITypeProvider
  // in terms of the provided types below.
  inherit TypeProviderForNamespaces(config)

  let namespaceName = "Samples.HelloWorldTypeProvider"
  let thisAssembly = Assembly.GetExecutingAssembly()

  // Make one provided type, called TypeN.
  let makeOneProvidedType (n:int) =
  …
  // Now generate 100 types
  let types = [ for i in 1 .. 100 -> makeOneProvidedType i ]

  // And add them to the namespace
  do this.AddNamespace(namespaceName, types)

[<assembly:TypeProviderAssembly>]
do()

⚠️ Note: 이 코드는 온라인 샘플과 차이가 있을 수 있어요.

이 제공자를 쓸려면 Visual Studio를 별도 인스턴스로 열고 F# 스크립트를 만든 다음, 아래 코드처럼 #r로 스크립트에 제공자를 참조로 추가하면 돼요:

#r @".\bin\Debug\Samples.HelloWorldTypeProvider.dll"

let obj1 = Samples.HelloWorldTypeProvider.Type1("some data")

let obj2 = Samples.HelloWorldTypeProvider.Type1("some other data")

obj1.InstanceProperty
obj2.InstanceProperty

[ for index in 0 .. obj1.InstanceProperty-1 -> obj1.InstanceMethod(index) ]
[ for index in 0 .. obj2.InstanceProperty-1 -> obj2.InstanceMethod(index) ]

let data1 = Samples.HelloWorldTypeProvider.Type1.NestedType.StaticProperty35

그다음 Samples.HelloWorldTypeProvider 네임스페이스 아래에서 형식 제공자가 생성한 형식들을 찾아보세요.

제공자를 다시 컴파일하기 전에는 제공자 DLL을 사용 중인 Visual Studio와 F# Interactive 인스턴스를 전부 닫아야 해요. 그렇지 않으면 출력 DLL이 잠겨 있어 빌드 오류가 생겨요.

print 문으로 이 제공자를 디버깅하려면, 제공자에서 문제가 드러나는 스크립트를 만든 뒤 다음 코드를 쓰면 돼요:

fsc.exe -r:bin\Debug\HelloWorldTypeProvider.dll script.fsx

Visual Studio로 디버깅하려면 관리자 권한으로 Visual Studio용 개발자 명령 프롬프트(Developer Command Prompt)를 열고 다음 명령을 실행하세요:

devenv.exe /debugexe fsc.exe -r:bin\Debug\HelloWorldTypeProvider.dll script.fsx

혹은 Visual Studio를 열고 Debug 메뉴에서 **Debug/Attach to process…**를 골라, 스크립트를 편집 중인 다른 devenv 프로세스에 연결해도 돼요. 이 방법을 쓰면 두 번째 인스턴스에 대화형으로(완전한 IntelliSense 등과 함께) 식을 입력하면서, 형식 제공자에서 특정 로직을 훨씬 쉽게 겨냥할 수 있어요.

Just My Code 디버깅을 끄면 생성된 코드의 오류를 더 잘 찾을 수 있어요. 이 기능을 켜고 끄는 방법은 Navigating through Code with the Debugger를 참고하세요. 또 Debug 메뉴에서 Exceptions를 고르거나 Ctrl+Alt+E 키로 Exceptions 대화 상자를 연 다음, Common Language Runtime Exceptions 아래에서 Thrown 확인란을 선택하면 첫 예외(first-chance exception)를 잡도록 설정할 수 있어요.

형식 제공자의 구현 (Implementation of the Type Provider)

이 절에서는 형식 제공자 구현의 주요 부분을 단계별로 살펴볼게요. 먼저 커스텀 형식 제공자 자신의 형식을 정의해요:

[<TypeProvider>]
type SampleTypeProvider(config: TypeProviderConfig) as this =

이 형식은 public이어야 하고, 별도의 F# 프로젝트가 이 형식을 담은 어셈블리를 참조할 때 컴파일러가 형식 제공자로 인식하게 하려면 TypeProvider 특성으로 표시해야 해요. config 매개변수는 선택 사항이며, 있으면 F# 컴파일러가 만드는 형식 제공자 인스턴스에 대한 컨텍스트 구성 정보를 담아요.

다음으로 ITypeProvider 인터페이스를 구현해요. 여기서는 ProvidedTypes API의 TypeProviderForNamespaces 형식을 기본 형식(base type)으로 사용했어요. 이 헬퍼 형식은 각각 유한한 개수의 고정된, 즉시(eagerly) 제공되는 형식을 직접 담는, 유한한 개수의 즉시 제공되는 네임스페이스를 제공할 수 있어요. 여기서 "즉시(eagerly)"란 제공자가 형식이 필요하지 않거나 쓰이지 않아도 그 형식을 생성한다는 뜻이에요.

inherit TypeProviderForNamespaces(config)

다음으로, 제공되는 형식의 네임스페이스를 지정하고 형식 제공자 어셈블리 자체를 찾는 로컬 private 값들을 정의해요. 이 어셈블리는 나중에 제공되는 소거된 형식들의 논리적 부모 형식으로 사용돼요.

let namespaceName = "Samples.HelloWorldTypeProvider"
let thisAssembly = Assembly.GetExecutingAssembly()

다음으로 Type1Type100 각각의 형식을 제공하는 함수를 만들어요. 이 함수는 이 문서의 뒷부분에서 더 자세히 설명할게요.

let makeOneProvidedType (n:int) = …

이제 제공되는 형식 100개를 생성해요:

let types = [ for i in 1 .. 100 -> makeOneProvidedType i ]

다음으로, 이 형식들을 제공되는 네임스페이스로 추가해요:

do this.AddNamespace(namespaceName, types)

마지막으로, 형식 제공자 DLL을 만들고 있음을 나타내는 어셈블리 특성을 추가해요:

[<assembly:TypeProviderAssembly>]
do()

형식 하나와 그 멤버 제공하기 (Providing One Type And Its Members)

makeOneProvidedType 함수가 형식 하나를 제공하는 실제 작업을 담당해요. 이 함수의 구현을 설명해 드릴게요. 우선 제공되는 형식을 만들어요(예: n = 1이면 Type1, n = 57이면 Type57).

let makeOneProvidedType (n:int) =
…

이 형식의 핵심 정의는 이래요:

// This is the provided type. It is an erased provided type and, in compiled code,
// will appear as type 'obj'.
let t = ProvidedTypeDefinition(thisAssembly, namespaceName,
                               "Type" + string n,
                               baseType = Some typeof<obj>)

여기서 짚고 넘어갈 점이 몇 가지 있어요:

  • 이 제공 형식은 소거(erased) 돼요. 기본 형식을 obj로 지정했기 때문에, 컴파일된 코드에서는 인스턴스가 obj 형식 값으로 나타나요.
  • 중첩되지 않은(non-nested) 형식을 지정할 때는 어셈블리와 네임스페이스를 반드시 지정해야 해요. 소거된 형식의 경우 그 어셈블리는 형식 제공자 어셈블리 자신이어야 해요.

다음으로 형식에 XML 문서를 추가해요. 이 문서는 지연(delayed) 처리돼요. 즉 호스트 컴파일러가 필요로 할 때만 계산돼요.

t.AddXmlDocDelayed (fun () -> $"This provided type {"Type" + string n}")

이제 형식에 제공되는 정적 속성(static property)을 추가해요:

let staticProp = ProvidedProperty(propertyName = "StaticProperty",
                                  propertyType = typeof<string>,
                                  isStatic = true,
                                  getterCode = (fun args -> <@@ "Hello!" @@>))

이 속성을 가져오면(get) 항상 문자열 "Hello!"로 평가돼요. 속성의 GetterCode는 F# Code Quotations (F#)을 사용하는데, 호스트 컴파일러가 속성을 가져올 때 생성하는 코드를 나타내요.

staticProp.AddXmlDocDelayed(fun () -> "This is a static property")

이제 제공되는 속성을 제공되는 형식에 붙여요. 제공되는 멤버는 반드시 정확히 하나의 형식에만 붙여야 해요. 그렇지 않으면 그 멤버는 결코 접근할 수 없어요.

t.AddMember staticProp

이제 매개변수를 받지 않는 제공 생성자(constructor)를 만들어요:

let ctor = ProvidedConstructor(parameters = [ ],
                               invokeCode = (fun args -> <@@ "The object data" :> obj @@>))

생성자의 InvokeCode는 F# quotation을 반환하는데, 호스트 컴파일러가 생성자를 호출할 때 생성하는 코드를 나타내요. 예를 들어 다음 생성자를 쓸 수 있어요:

new Type10()

이렇게 하면 내부 데이터가 "The object data"인 제공 형식 인스턴스가 만들어져요. 인용(quoted) 코드에는 obj로의 변환이 포함되는데, 그게 이 제공 형식의 소거(아까 제공 형식을 선언할 때 지정한)이기 때문이에요.

생성자에 XML 문서를 추가하고, 제공되는 생성자를 제공되는 형식에 추가해요:

ctor.AddXmlDocDelayed(fun () -> "This is a constructor")
t.AddMember ctor

이제 매개변수 하나를 받는 두 번째 제공 생성자를 만들어요:

let ctor2 =
ProvidedConstructor(parameters = [ ProvidedParameter("data",typeof<string>) ],
                    invokeCode = (fun args -> <@@ (%%(args[0]) : string) :> obj @@>))

이 생성자의 InvokeCode도 다시 F# quotation을 반환해요. 이번엔 메서드 호출을 위해 호스트 컴파일러가 생성한 코드를 나타내요. 예를 들어 다음 생성자를 쓸 수 있어요:

new Type10("ten")

내부 데이터가 "ten"인 제공 형식 인스턴스가 만들어지죠. 눈치채셨을 수도 있는데, InvokeCode 함수는 quotation을 반환해요. 이 함수의 입력은 생성자 매개변수마다 하나씩인 식(expression)의 목록이에요. 이 경우 단일 매개변수 값을 나타내는 식이 args[0]에 들어 있어요. 생성자 호출을 위한 코드는 반환 값을 소거 형식인 obj로 강제 변환(cast)해요. 두 번째 제공 생성자를 형식에 추가한 다음, 제공되는 인스턴스 속성을 만들어요:

let instanceProp =
    ProvidedProperty(propertyName = "InstanceProperty",
                     propertyType = typeof<int>,
                     getterCode= (fun args ->
                        <@@ ((%%(args[0]) : obj) :?> string).Length @@>))
instanceProp.AddXmlDocDelayed(fun () -> "This is an instance property")
t.AddMember instanceProp

이 속성을 가져오면 표현(representation) 객체인 문자열의 길이가 반환돼요. GetterCode 속성은 속성을 가져올 때 호스트 컴파일러가 생성할 코드를 지정하는 F# quotation을 반환해요. InvokeCode와 마찬가지로 GetterCode 함수도 quotation을 반환하고, 호스트 컴파일러는 인자 목록과 함께 이 함수를 호출해요. 여기서는 getter가 호출되는 인스턴스를 나타내는 단일 식만 인자로 들어오는데, args[0]으로 접근할 수 있어요. 그러면 GetterCode 구현은 결과 quotation을 소거 형식 obj에서 이어 붙이고, 그 객체가 문자열임을 컴파일러의 형식 검사 메커니즘에 만족시키기 위해 캐스트를 사용해요. makeOneProvidedType의 다음 부분은 매개변수 하나를 받는 인스턴스 메서드를 제공해요:

let instanceMeth =
    ProvidedMethod(methodName = "InstanceMethod",
                   parameters = [ProvidedParameter("x",typeof<int>)],
                   returnType = typeof<char>,
                   invokeCode = (fun args ->
                       <@@ ((%%(args[0]) : obj) :?> string).Chars(%%(args[1]) : int) @@>))

instanceMeth.AddXmlDocDelayed(fun () -> "This is an instance method")
// Add the instance method to the type.
t.AddMember instanceMeth

마지막으로, 중첩 속성 100개를 담은 중첩 형식(nested type)을 만들어요. 이 중첩 형식과 그 속성의 생성은 지연(delayed) 처리돼요. 즉 필요할 때만 계산돼요.

t.AddMembersDelayed(fun () ->
  let nestedType = ProvidedTypeDefinition("NestedType", Some typeof<obj>)

  nestedType.AddMembersDelayed (fun () ->
    let staticPropsInNestedType =
      [
          for i in 1 .. 100 ->
              let valueOfTheProperty = "I am string "  + string i

              let p =
                ProvidedProperty(propertyName = "StaticProperty" + string i,
                  propertyType = typeof<string>,
                  isStatic = true,
                  getterCode= (fun args -> <@@ valueOfTheProperty @@>))

              p.AddXmlDocDelayed(fun () ->
                  $"This is StaticProperty{i} on NestedType")

    	    p
      ]

    staticPropsInNestedType)

  [nestedType])

소거된 제공 형식의 이해 (Details about Erased Provided Types)

이 절의 예시는 소거된 제공 형식(erased provided types) 만 제공하는데, 이런 형식은 특히 다음 상황에서 유용해요:

  • 데이터와 메서드만 담고 있는 정보 공간을 위한 제공자를 쓸 때.
  • 정보 공간을 실제로 쓰는 데 있어 정확한 런타임 형식 의미론이 중요하지 않을 때.
  • 정보 공간이 너무 크고 서로 얽혀 있어서, 그 정보 공간을 위해 실제 .NET 형식을 생성하는 것이 기술적으로 불가능한 경우.

이 예시에서 각 제공 형식은 obj 형식으로 소거되며, 컴파일된 코드에서 모든 형식 사용이 obj 형식으로 나타나요. 실제로 이 예시의 내부 객체는 문자열이지만, .NET 컴파일 코드에서는 System.Object로 나타나요. 형식 소거를 쓰는 모든 경우와 마찬가지로, 명시적 박싱(boxing)·언박싱(unboxing)·캐스트를 사용하면 소거된 형식을 우회(subvert)할 수 있어요. 이 경우 객체를 사용할 때 유효하지 않은 캐스트 예외가 발생할 수 있어요. 제공자 런타임은 자신만의 private 표현 형식을 정의해 잘못된 표현(false representation)을 막을 수도 있어요. 소거된 형식은 F# 자체에서는 정의할 수 없어요. 소거될 수 있는 것은 제공 형식뿐이에요. 형식 제공자에 소거된 형식을 쓰는 것과, 소거된 형식을 제공하는 제공자를 쓰는 것, 둘 다의 실용적·의미론적 영향력을 반드시 이해해야 해요. 소거된 형식은 실제 .NET 형식이 아니에요. 따라서 형식에 대한 정확한 리플렉션(reflection)을 할 수 없고, 런타임 캐스트처럼 정확한 런타임 형식 의미론에 의존하는 기법을 쓰면 소거된 형식을 우회할 수 있어요. 그리고 소거된 형식의 우회는 흔히 런타임에 형식 캐스트 예외로 이어져요.

소거된 제공 형식의 표현 고르기 (Choosing Representations for Erased Provided Types)

소거된 제공 형식의 어떤 용도에서는 표현(representation)이 전혀 필요하지 않아요. 예를 들어 소거된 제공 형식이 정적 속성·멤버만 담고 생성자가 없고, 그 형식의 인스턴스를 반환하는 메서드나 속성도 없다면요. 소거된 제공 형식의 인스턴스에 도달할 수 있다면 다음 질문들을 고려해야 해요.

제공 형식의 소거(erasure)란 무엇인가요?

  • 제공 형식의 소거란 그 형식이 컴파일된 .NET 코드에 나타나는 방식이에요.
  • 제공된 소거 클래스 형식의 소거는 항상 그 형식의 상속 체인에서 첫 번째로 소거되지 않은 기본 형식이에요.
  • 제공된 소거 인터페이스 형식의 소거는 항상 System.Object예요.

제공 형식의 표현(representations)이란 무엇인가요?

  • 소거된 제공 형식의 가능한 객체 집합을 그 표현이라고 불러요. 이 문서의 예시에서 모든 소거된 제공 형식 Type1..Type100의 표현은 항상 문자열 객체예요.

제공 형식의 모든 표현은 그 제공 형식의 소거와 호환되어야 해요. (그렇지 않으면 F# 컴파일러가 형식 제공자 사용에 대해 오류를 내거나, 유효하지 않은 검증 불가(unverifiable) .NET 코드가 생성돼요. 유효하지 않은 표현을 주는 코드를 반환하는 형식 제공자는 유효하지 않아요.)

제공 객체의 표현을 고르는 방법은 두 가지가 있는데, 둘 다 아주 흔해요:

  • 기존 .NET 형식 위에 강한 형식의 래퍼만 제공한다면, 대개 자신의 형식이 그 형식으로 소거되고, 그 형식의 인스턴스를 표현으로 사용하거나 둘 다 하는 것이 합리적이에요. 이 접근은 그 형식의 기존 메서드 대부분이 강한 형식 버전을 쓸 때도 여전히 의미 있을 때 적합해요.
  • 기존 .NET API와 크게 다른 API를 만들고 싶다면, 제공 형식의 소거·표현이 될 런타임 형식을 직접 만드는 것이 합리적이에요.

이 문서의 예시는 제공 객체의 표현으로 문자열을 써요. 표현에 다른 객체를 쓰는 것이 적절할 때도 자주 있어요. 예를 들어 사전(dictionary)을 속성 주머니(property bag)로 쓸 수 있어요:

ProvidedConstructor(parameters = [],
    invokeCode= (fun args -> <@@ (new Dictionary<string,obj>()) :> obj @@>))

대안으로, 런타임에 표현을 구성하는 데 쓰일 형식을 제공자 안에 정의하고, 함께 런타임 연산 하나 이상을 정의할 수도 있어요:

type DataObject() =
    let data = Dictionary<string,obj>()
    member x.RuntimeOperation() = data.Count

그러면 제공 멤버들이 이 객체 형식의 인스턴스를 구성할 수 있어요:

ProvidedConstructor(parameters = [],
    invokeCode= (fun args -> <@@ (new DataObject()) :> obj @@>))

이 경우 (선택적으로) ProvidedTypeDefinition을 만들 때 이 형식을 baseType으로 지정해서 형식 소거로도 사용할 수 있어요:

ProvidedTypeDefinition(…, baseType = Some typeof<DataObject> )
…
ProvidedConstructor(…, InvokeCode = (fun args -> <@@ new DataObject() @@>), …)

핵심 교훈 (Key Lessons)

앞 절에서는 여러 형식·속성·메서드를 제공하는 간단한 소거형 형식 제공자를 만드는 방법을 살펴봤어요. 또 형식 소거의 개념, 형식 제공자에서 소거된 형식을 제공할 때의 장단점, 소거된 형식의 표현에 대해서도 다뤘어요.

정적 매개변수를 사용하는 형식 제공자 (A Type Provider That Uses Static Parameters)

정적 데이터로 형식 제공자를 매개변수화할 수 있는 능력은, 제공자가 로컬·원격 데이터에 전혀 접근할 필요가 없는 경우에도 참 흥미로운 시나리오를 많이 만들어 줘요. 이 절에서는 그런 제공자를 조립하기 위한 기초 기법들을 배워볼게요.

형식 검사되는 정규식 제공자 (Type Checked Regex Provider)

.NET의 Regex 라이브러리를 감싸서 다음 컴파일 타임 보장을 제공하는 인터페이스를 만드는 정규식용 형식 제공자를 구현하고 싶다고 상상해 보세요:

  • 정규식이 유효한지 검증한다.
  • 정규식의 그룹 이름에 기반한 명명된 속성을 매치(match)에 제공한다.

이 절에서는 이런 이점을 누리도록 정규식 패턴으로 매개변수화되는 RegexTyped 형식을 만들기 위해 형식 제공자를 쓰는 방법을 보여 드릴게요. 제공된 패턴이 유효하지 않으면 컴파일러가 오류를 보고하고, 형식 제공자는 패턴에서 그룹들을 추출해서 매치에서 명명된 속성으로 접근할 수 있게 해 줘요. 형식 제공자를 설계할 때는 API가 최종 사용자에게 어떻게 보여야 하는지, 그 설계가 .NET 코드로 어떻게 옮겨질지를 고려해야 해요. 다음 예시는 그런 API를 써서 지역번호(area code)의 구성요소를 얻는 방법을 보여 줘요:

type T = RegexTyped< @"(?<AreaCode>^\d{3})-(?<PhoneNumber>\d{3}-\d{4}$)">
let reg = T()
let result = T.IsMatch("425-555-2345")
let r = reg.Match("425-555-2345").Group_AreaCode.Value //r equals "425"

다음 예시는 형식 제공자가 위 호출들을 어떻게 변환하는지 보여 줘요:

let reg = new Regex(@"(?<AreaCode>^\d{3})-(?<PhoneNumber>\d{3}-\d{4}$)")
let result = reg.IsMatch("425-123-2345")
let r = reg.Match("425-123-2345").Groups["AreaCode"].Value //r equals "425"

짚고 넘어갈 점이 있어요:

  • 표준 Regex 형식이 매개변수화된 RegexTyped 형식을 나타내요.
  • RegexTyped 생성자는 패턴의 정적 형식 인자를 전달하면서 Regex 생성자 호출로 이어져요.
  • Match 메서드의 결과는 표준 Match 형식으로 나타나요.
  • 각 명명된 그룹은 제공 속성으로 이어지고, 그 속성에 접근하면 매치의 Groups 컬렉션에 인덱서(indexer)를 쓰는 결과가 돼요.

이런 제공자를 구현하는 로직의 핵심은 다음 코드예요. 이 예시는 제공 형식에 모든 멤버를 추가하는 부분은 생략했으니, 추가되는 각 멤버에 대한 내용은 이 문서 뒷부분의 해당 절을 참고하세요.

namespace Samples.FSharp.RegexTypeProvider

open System.Reflection
open Microsoft.FSharp.Core.CompilerServices
open Samples.FSharp.ProvidedTypes
open System.Text.RegularExpressions

[<TypeProvider>]
type public CheckedRegexProvider() as this =
    inherit TypeProviderForNamespaces()

    // Get the assembly and namespace used to house the provided types
    let thisAssembly = Assembly.GetExecutingAssembly()
    let rootNamespace = "Samples.FSharp.RegexTypeProvider"
    let baseTy = typeof<obj>
    let staticParams = [ProvidedStaticParameter("pattern", typeof<string>)]

    let regexTy = ProvidedTypeDefinition(thisAssembly, rootNamespace, "RegexTyped", Some baseTy)

    do regexTy.DefineStaticParameters(
        parameters=staticParams,
        instantiationFunction=(fun typeName parameterValues ->

          match parameterValues with
          | [| :? string as pattern|] ->

            // Create an instance of the regular expression.
            //
            // This will fail with System.ArgumentException if the regular expression is not valid.
            // The exception will escape the type provider and be reported in client code.
            let r = System.Text.RegularExpressions.Regex(pattern)

            // Declare the typed regex provided type.
            // The type erasure of this type is 'obj', even though the representation will always be a Regex
            // This, combined with hiding the object methods, makes the IntelliSense experience simpler.
            let ty =
              ProvidedTypeDefinition(
                thisAssembly,
                rootNamespace,
                typeName,
                baseType = Some baseTy)

            ...

            ty
          | _ -> failwith "unexpected parameter values"))

    do this.AddNamespace(rootNamespace, [regexTy])

[<TypeProviderAssembly>]
do ()

여기서도 짚어 볼 점이 몇 가지 있어요:

  • 형식 제공자는 정적 매개변수 두 개를 받아요. pattern은 필수이고, options는 선택이에요(기본값이 제공되니까요).
  • 정적 인자가 제공된 후 정규식 인스턴스를 만들어요. 이 인스턴스는 Regex가 잘못된 형태면 예외를 던지는데, 이 오류는 사용자에게 보고돼요.
  • DefineStaticParameters 콜백 안에서, 인자가 제공된 후 반환될 형식을 정의해요.
  • 이 코드는 HideObjectMethodstrue로 설정해서 IntelliSense 경험을 깔끔하게 유지해요. 이 특성은 제공 객체의 IntelliSense 목록에서 Equals, GetHashCode, Finalize, GetType 멤버를 숨겨요.
  • 메서드의 기본 형식으로 obj를 쓰지만, 이 형식의 런타임 표현으로는 (다음 예시에서 보듯) Regex 객체를 사용해요.
  • 잘못된 정규식이면 Regex 생성자 호출이 ArgumentException을 던져요. 컴파일러가 이 예외를 잡아 컴파일 타임 또는 Visual Studio 편집기에서 사용자에게 오류 메시지를 보고해요. 이 예외 덕분에 애플리케이션을 실행하지 않고도 정규식을 검증할 수 있어요.

위에서 정의한 형식은 아직 의미 있는 메서드나 속성이 없어서 쓸모가 없어요. 먼저 정적 IsMatch 메서드를 추가해 볼게요:

let isMatch =
    ProvidedMethod(
        methodName = "IsMatch",
        parameters = [ProvidedParameter("input", typeof<string>)],
        returnType = typeof<bool>,
        isStatic = true,
        invokeCode = fun args -> <@@ Regex.IsMatch(%%args[0], pattern) @@>)

isMatch.AddXmlDoc "Indicates whether the regular expression finds a match in the specified input string."
ty.AddMember isMatch

위 코드는 문자열을 입력으로 받아 bool을 반환하는 IsMatch 메서드를 정의해요. 까다로운 부분은 InvokeCode 정의 안에서 args 인자를 쓰는 것뿐이에요. 여기서 args는 이 메서드의 인자들을 나타내는 quotation 목록이에요. 메서드가 인스턴스 메서드면 첫 인자는 this 인자를 나타내요. 하지만 정적 메서드의 경우 인자는 모두 그 메서드의 명시적 인자일 뿐이에요. 인용된 값의 형식은 지정된 반환 형식(여기선 bool)과 일치해야 한다는 점에 주의하세요. 또 이 코드는 AddXmlDoc 메서드를 써서 제공 메서드에 IntelliSense로 제공할 유용한 문서도 넣어 줘요.

다음으로 인스턴스 Match 메서드를 추가해요. 그런데 이 메서드는 제공되는 Match 형식의 값을 반환해야 그룹들을 강한 형식으로 접근할 수 있어요. 그래서 먼저 Match 형식을 선언해요. 이 형식은 정적 인자로 제공된 패턴에 의존하므로, 매개변수화된 형식 정의 안에 중첩되어야 해요:

let matchTy =
    ProvidedTypeDefinition(
        "MatchType",
        baseType = Some baseTy,
        hideObjectMethods = true)

ty.AddMember matchTy

그다음 Match 형식에 그룹마다 속성 하나를 추가해요. 런타임에 매치는 Match 값으로 나타나므로, 속성을 정의하는 quotation은 Groups 인덱스 속성을 사용해 관련 그룹을 가져와야 해요.

for group in r.GetGroupNames() do
    // Ignore the group named 0, which represents all input.
    if group <> "0" then
    let prop =
      ProvidedProperty(
        propertyName = group,
        propertyType = typeof<Group>,
        getterCode = fun args -> <@@ ((%%args[0]:obj) :?> Match).Groups[group] @@>)
        prop.AddXmlDoc($"Gets the ""{group}"" group from this match")
    matchTy.AddMember prop

여기서도 제공 속성에 XML 문서를 추가하고 있다는 점을 다시 언급할게요. 또 GetterCode 함수가 제공되면 속성을 읽을 수 있고, SetterCode 함수가 제공되면 속성을 쓸 수 있어서, 그 결과로 만들어지는 속성은 읽기 전용이에요.

이제 이 Match 형식의 값을 반환하는 인스턴스 메서드를 만들 수 있어요:

let matchMethod =
    ProvidedMethod(
        methodName = "Match",
        parameters = [ProvidedParameter("input", typeof<string>)],
        returnType = matchTy,
        invokeCode = fun args -> <@@ ((%%args[0]:obj) :?> Regex).Match(%%args[1]) :> obj @@>)

matchMeth.AddXmlDoc "Searches the specified input string for the first occurrence of this regular expression"

ty.AddMember matchMeth

인스턴스 메서드를 만드는 중이므로 args[0]은 메서드가 호출되는 RegexTyped 인스턴스를, args[1]은 입력 인자를 나타내요.

마지막으로, 제공 형식의 인스턴스를 만들 수 있도록 생성자를 제공해요:

let ctor =
    ProvidedConstructor(
        parameters = [],
        invokeCode = fun args -> <@@ Regex(pattern, options) :> obj @@>)

ctor.AddXmlDoc("Initializes a regular expression instance.")

ty.AddMember ctor

이 생성자는 단순히 표준 .NET Regex 인스턴스를 만드는 것으로 소거되며, obj가 제공 형식의 소거이므로 다시 객체로 박싱돼요. 이렇게 하면 이 문서 앞부분에서 지정한 샘플 API 사용법이 기대대로 동작해요. 다음 코드는 완전하고 최종적인 구현이에요:

namespace Samples.FSharp.RegexTypeProvider

open System.Reflection
open Microsoft.FSharp.Core.CompilerServices
open Samples.FSharp.ProvidedTypes
open System.Text.RegularExpressions

[<TypeProvider>]
type public CheckedRegexProvider() as this =
    inherit TypeProviderForNamespaces()

    // Get the assembly and namespace used to house the provided types.
    let thisAssembly = Assembly.GetExecutingAssembly()
    let rootNamespace = "Samples.FSharp.RegexTypeProvider"
    let baseTy = typeof<obj>
    let staticParams = [ProvidedStaticParameter("pattern", typeof<string>)]

    let regexTy = ProvidedTypeDefinition(thisAssembly, rootNamespace, "RegexTyped", Some baseTy)

    do regexTy.DefineStaticParameters(
        parameters=staticParams,
        instantiationFunction=(fun typeName parameterValues ->

            match parameterValues with
            | [| :? string as pattern|] ->

                // Create an instance of the regular expression.

                let r = System.Text.RegularExpressions.Regex(pattern)

                // Declare the typed regex provided type.

                let ty =
                    ProvidedTypeDefinition(
                        thisAssembly,
                        rootNamespace,
                        typeName,
                        baseType = Some baseTy)

                ty.AddXmlDoc "A strongly typed interface to the regular expression '%s'"

                // Provide strongly typed version of Regex.IsMatch static method.
                let isMatch =
                    ProvidedMethod(
                        methodName = "IsMatch",
                        parameters = [ProvidedParameter("input", typeof<string>)],
                        returnType = typeof<bool>,
                        isStatic = true,
                        invokeCode = fun args -> <@@ Regex.IsMatch(%%args[0], pattern) @@>)

                isMatch.AddXmlDoc "Indicates whether the regular expression finds a match in the specified input string"

                ty.AddMember isMatch

                // Provided type for matches
                // Again, erase to obj even though the representation will always be a Match
                let matchTy =
                    ProvidedTypeDefinition(
                        "MatchType",
                        baseType = Some baseTy,
                        hideObjectMethods = true)

                // Nest the match type within parameterized Regex type.
                ty.AddMember matchTy

                // Add group properties to match type
                for group in r.GetGroupNames() do
                    // Ignore the group named 0, which represents all input.
                    if group <> "0" then
                        let prop =
                          ProvidedProperty(
                            propertyName = group,
                            propertyType = typeof<Group>,
                            getterCode = fun args -> <@@ ((%%args[0]:obj) :?> Match).Groups[group] @@>)
                        prop.AddXmlDoc(sprintf @"Gets the ""%s"" group from this match" group)
                        matchTy.AddMember(prop)

                // Provide strongly typed version of Regex.Match instance method.
                let matchMeth =
                  ProvidedMethod(
                    methodName = "Match",
                    parameters = [ProvidedParameter("input", typeof<string>)],
                    returnType = matchTy,
                    invokeCode = fun args -> <@@ ((%%args[0]:obj) :?> Regex).Match(%%args[1]) :> obj @@>)
                matchMeth.AddXmlDoc "Searches the specified input string for the first occurrence of this regular expression"

                ty.AddMember matchMeth

                // Declare a constructor.
                let ctor =
                  ProvidedConstructor(
                    parameters = [],
                    invokeCode = fun args -> <@@ Regex(pattern) :> obj @@>)

                // Add documentation to the constructor.
                ctor.AddXmlDoc "Initializes a regular expression instance"

                ty.AddMember ctor

                ty
            | _ -> failwith "unexpected parameter values"))

    do this.AddNamespace(rootNamespace, [regexTy])

[<TypeProviderAssembly>]
do ()

핵심 교훈 (Key Lessons)

이 절에서는 정적 매개변수에 대해 동작하는 형식 제공자를 만드는 방법을 살펴봤어요. 제공자는 정적 매개변수를 검사하고 그 값에 기반한 연산을 제공했어요.

로컬 데이터에 기반한 형식 제공자 (A Type Provider That Is Backed By Local Data)

형식 제공자가 정적 매개변수뿐 아니라 로컬·원격 시스템의 정보에 기반한 API를 제시하길 원하는 경우도 자주 있어요. 이 절에서는 로컬 데이터 파일 같은 로컬 데이터에 기반한 형식 제공자를 다뤄 볼게요.

간단한 CSV 파일 제공자 (Simple CSV File Provider)

간단한 예시로, 쉼표로 구분된 값(Comma Separated Value, CSV) 형식의 과학 데이터에 접근하는 형식 제공자를 생각해 봐요. 이 절에서는 CSV 파일에 헤더 행 다음에 부동 소수점 데이터가 온다고 가정해요. 아래 표가 그 모습을 보여 줘요:

이 절에서는 Distance 속성이 float<meter> 형식이고 Time 속성이 float<second> 형식인 행들을 얻을 수 있는 형식을 제공하는 방법을 보여 드릴게요. 단순함을 위해 다음을 가정할게요:

  • 헤더 이름은 단위가 없거나 "Name (unit)" 형태이며 쉼표를 포함하지 않는다.
  • 단위는 모두 FSharp.Data.UnitSystems.SI.UnitNames Module (F#) 모듈이 정의하는 것처럼 국제단위계(SI) 단위다.
  • 단위는 모두 단순하다(예: meter). 복합 단위(예: meter/second)는 없다.
  • 모든 열은 부동 소수점 데이터를 담는다.

더 완전한 제공자는 이런 제약을 완화할 거예요.

여기서도 첫 단계는 API가 어떻게 보여야 할지를 고려하는 일이에요. 위 표의 내용을 쉼표로 구분한 형식으로 담은 info.csv 파일이 주어졌을 때, 제공자 사용자는 다음 예시와 비슷한 코드를 쓸 수 있어야 해요:

let info = new MiniCsv<"info.csv">()
for row in info.Data do
let time = row.Time
printfn $"{float time}"

이 경우 컴파일러는 이 호출들을 다음 예시처럼 변환해야 해요:

let info = new CsvFile("info.csv")
for row in info.Data do
let (time:float) = row[1]
printfn $"%f{float time}"

이 최적의 변환을 위해서는 형식 제공자가 제공자 어셈블리 안에 CsvFile이라는 실제 형식을 정의해야 해요. 형식 제공자는 중요한 로직을 감싸기 위해 헬퍼 형식·메서드 몇 개에 의존하는 경우가 많아요. 측정 단위는 런타임에 소거되므로, 행의 소거 형식으로 float[]를 쓸 수 있어요. 컴파일러는 각각의 서로 다른 열이 서로 다른 측정 단위 형식을 갖는 것으로 취급해요. 예를 들어 이 예시의 첫 열은 float<meter>, 두 번째 열은 float<second> 형식이에요. 하지만 소거된 표현은 아주 단순하게 유지될 수 있어요.

다음 코드가 구현의 핵심이에요:

// Simple type wrapping CSV data
type CsvFile(filename) =
    // Cache the sequence of all data lines (all lines but the first)
    let data =
        seq {
            for line in File.ReadAllLines(filename) |> Seq.skip 1 ->
                line.Split(',') |> Array.map float
        }
        |> Seq.cache
    member _.Data = data

[<TypeProvider>]
type public MiniCsvProvider(cfg:TypeProviderConfig) as this =
    inherit TypeProviderForNamespaces(cfg)

    // Get the assembly and namespace used to house the provided types.
    let asm = System.Reflection.Assembly.GetExecutingAssembly()
    let ns = "Samples.FSharp.MiniCsvProvider"

    // Create the main provided type.
    let csvTy = ProvidedTypeDefinition(asm, ns, "MiniCsv", Some(typeof<obj>))

    // Parameterize the type by the file to use as a template.
    let filename = ProvidedStaticParameter("filename", typeof<string>)
    do csvTy.DefineStaticParameters([filename], fun tyName [| :? string as filename |] ->

        // Resolve the filename relative to the resolution folder.
        let resolvedFilename = Path.Combine(cfg.ResolutionFolder, filename)

        // Get the first line from the file.
        let headerLine = File.ReadLines(resolvedFilename) |> Seq.head

        // Define a provided type for each row, erasing to a float[].
        let rowTy = ProvidedTypeDefinition("Row", Some(typeof<float[]>))

        // Extract header names from the file, splitting on commas.
        // use Regex matching to get the position in the row at which the field occurs
        let headers = Regex.Matches(headerLine, "[^,]+")

        // Add one property per CSV field.
        for i in 0 .. headers.Count - 1 do
            let headerText = headers[i].Value

            // Try to decompose this header into a name and unit.
            let fieldName, fieldTy =
                let m = Regex.Match(headerText, @"(?<field>.+) \((?<unit>.+)\)")
                if m.Success then

                    let unitName = m.Groups["unit"].Value
                    let units = ProvidedMeasureBuilder.Default.SI unitName
                    m.Groups["field"].Value, ProvidedMeasureBuilder.Default.AnnotateType(typeof<float>,[units])

                else
                    // no units, just treat it as a normal float
                    headerText, typeof<float>

            let prop =
                ProvidedProperty(fieldName, fieldTy,
                    getterCode = fun [row] -> <@@ (%%row:float[])[i] @@>)

            // Add metadata that defines the property's location in the referenced file.
            prop.AddDefinitionLocation(1, headers[i].Index + 1, filename)
            rowTy.AddMember(prop)

        // Define the provided type, erasing to CsvFile.
        let ty = ProvidedTypeDefinition(asm, ns, tyName, Some(typeof<CsvFile>))

        // Add a parameterless constructor that loads the file that was used to define the schema.
        let ctor0 =
            ProvidedConstructor([],
                invokeCode = fun [] -> <@@ CsvFile(resolvedFilename) @@>)
        ty.AddMember ctor0

        // Add a constructor that takes the file name to load.
        let ctor1 = ProvidedConstructor([ProvidedParameter("filename", typeof<string>)],
            invokeCode = fun [filename] -> <@@ CsvFile(%%filename) @@>)
        ty.AddMember ctor1

        // Add a more strongly typed Data property, which uses the existing property at run time.
        let prop =
            ProvidedProperty("Data", typedefof<seq<_>>.MakeGenericType(rowTy),
                getterCode = fun [csvFile] -> <@@ (%%csvFile:CsvFile).Data @@>)
        ty.AddMember prop

        // Add the row type as a nested type.
        ty.AddMember rowTy
        ty)

    // Add the type to the namespace.
    do this.AddNamespace(ns, [csvTy])

구현에 대해 짚어 볼 점이 몇 가지 있어요:

  • 오버로드된 생성자 덕분에 원본 파일이나 그와 동일한 스키마를 가진 파일을 읽을 수 있어요. 이 패턴은 로컬·원격 데이터 소스용 형식 제공자를 쓸 때 흔하며, 로컬 파일을 원격 데이터의 템플릿으로 사용할 수 있게 해 줘요.
  • 형식 제공자 생성자로 전달되는 TypeProviderConfig 값을 사용해 상대 파일 이름을 해석할 수 있어요.
  • AddDefinitionLocation 메서드로 제공 속성의 위치를 정의할 수 있어요. 그래서 제공 속성에 Go To Definition을 쓰면 Visual Studio에서 CSV 파일이 열려요.
  • ProvidedMeasureBuilder 형식으로 SI 단위를 조회하고 관련 float<_> 형식을 만들어 낼 수 있어요.

핵심 교훈 (Key Lessons)

이 절에서는 데이터 소스 자신 안에 담긴 간단한 스키마를 가진 로컬 데이터 소스용 형식 제공자를 만드는 방법을 살펴봤어요.

더 나아가기 (Going Further)

다음 절들은 더 깊이 공부하고 싶을 때 참고할 제안이에요.

소거된 형식의 컴파일된 코드 들여다보기 (A Look at the Compiled Code for Erased Types)

형식 제공자의 사용이 내보내지는(emitted) 코드와 어떻게 대응하는지 감을 잡으려면, 이 문서 앞부분에서 쓴 HelloWorldTypeProvider를 이용해 다음 함수를 살펴보세요.

let function1 () =
    let obj1 = Samples.HelloWorldTypeProvider.Type1("some data")
    obj1.InstanceProperty

그 결과를 ildasm.exe로 역컴파일(decompile)하면 이런 모습이에요:

.class public abstract auto ansi sealed Module1
extends [mscorlib]System.Object
{
.custom instance void [FSharp.Core]Microsoft.FSharp.Core.CompilationMappingAtt
ribute::.ctor(valuetype [FSharp.Core]Microsoft.FSharp.Core.SourceConstructFlags)
= ( 01 00 07 00 00 00 00 00 )
.method public static int32  function1() cil managed
{
// Code size       24 (0x18)
.maxstack  3
.locals init ([0] object obj1)
IL_0000:  nop
IL_0001:  ldstr      "some data"
IL_0006:  unbox.any  [mscorlib]System.Object
IL_000b:  stloc.0
IL_000c:  ldloc.0
IL_000d:  call       !!0 [FSharp.Core_2]Microsoft.FSharp.Core.LanguagePrimit
ives/IntrinsicFunctions::UnboxGeneric<string>(object)
IL_0012:  callvirt   instance int32 [mscorlib_3]System.String::get_Length()
IL_0017:  ret
} // end of method Module1::function1

} // end of class Module1

예시에서 보듯, Type1 형식과 InstanceProperty 속성에 대한 모든 언급이 소거되고, 관련된 런타임 형식에 대한 연산만 남아요.

형식 제공자의 설계·명명 규칙 (Design and Naming Conventions for Type Providers)

형식 제공자를 작성할 때는 다음 규칙을 지키세요.

연결 프로토콜용 제공자 (Providers for Connectivity Protocols): 일반적으로 OData·SQL 연결 같은 데이터·서비스 연결 프로토콜용 제공자 DLL 이름은 대부분 TypeProvider 또는 TypeProviders로 끝나야 해요. 예를 들어 다음 문자열과 비슷한 DLL 이름을 쓰세요:

Fabrikam.Management.BasicTypeProviders.dll

제공 형식이 해당 네임스페이스의 멤버가 되도록 하고, 구현한 연결 프로토콜을 나타내는지 확인하세요:

  Fabrikam.Management.BasicTypeProviders.WmiConnection<…>
  Fabrikam.Management.BasicTypeProviders.DataProtocolConnection<…>

일반 코딩용 유틸리티 제공자 (Utility Providers for General Coding): 정규식용 제공자 같은 유틸리티 형식 제공자는 다음 예시처럼 기본 라이브러리의 일부일 수 있어요:

#r "Fabrikam.Core.Text.Utilities.dll"

이 경우 제공 형식은 일반 .NET 설계 규칙에 따라 적절한 지점에 나타나요:

  open Fabrikam.Core.Text.RegexTyped

  let regex = new RegexTyped<"a+b+a+b+">()

단일 데이터 소스 (Singleton Data Sources): 어떤 형식 제공자는 전용 데이터 소스 하나에 연결하고 데이터만 제공해요. 이 경우 TypeProvider 접미사를 빼고 .NET 명명의 일반 규칙을 쓰세요:

#r "Fabrikam.Data.Freebase.dll"

let data = Fabrikam.Data.Freebase.Astronomy.Asteroids

자세한 내용은 이 문서 뒷부분에서 설명할 GetConnection 설계 규칙을 참고하세요.

형식 제공자의 설계 패턴 (Design Patterns for Type Providers)

다음 절은 형식 제공자를 작성할 때 쓸 수 있는 설계 패턴을 설명해요.

GetConnection 설계 패턴 (The GetConnection Design Pattern)

대부분의 형식 제공자는 FSharp.Data.TypeProviders.dll의 형식 제공자들이 쓰는 GetConnection 패턴을 따르도록 작성해야 해요. 다음 예시를 보세요:

#r "Fabrikam.Data.WebDataStore.dll"

type Service = Fabrikam.Data.WebDataStore<…static connection parameters…>

let connection = Service.GetConnection(…dynamic connection parameters…)

let data = connection.Astronomy.Asteroids
원격 데이터·서비스에 기반한 형식 제공자 (Type Providers Backed By Remote Data and Services)

원격 데이터·서비스에 기반한 형식 제공자를 만들기 전에는, 연결된(connected) 프로그래밍에 내재된 여러 문제를 고려해야 해요. 여기에는 다음 고려사항이 포함돼요:

  • 스키마 매핑 (schema mapping)
  • 스키마 변경 상황에서의 활성 상태(liveness)와 무효화(invalidation)
  • 스키마 캐싱 (schema caching)
  • 데이터 접근 연산의 비동기 구현
  • LINQ 쿼리를 포함한 쿼리 지원
  • 자격 증명과 인증 (credentials and authentication)

이 문서에서는 이 문제들을 더 탐구하지 않아요.

추가 작성 기법 (Additional Authoring Techniques)

여러분의 형식 제공자를 작성할 때 쓸 수 있는 추가 기법들이 몇 가지 있어요.

형식·멤버를 주문형(on-demand)으로 만들기 (Creating Types and Members On-Demand)

ProvidedType API에는 AddMember의 지연(delayed) 버전이 있어요.

  type ProvidedType =
      member AddMemberDelayed  : (unit -> MemberInfo)      -> unit
      member AddMembersDelayed : (unit -> MemberInfo list) -> unit

이 버전들은 주문형으로 만드는(on-demand) 형식 공간을 만드는 데 쓰여요.

배열 형식·제네릭 형식 인스턴스화 제공 (Providing Array types and Generic Type Instantiations)

배열 형식, byref 형식, 제네릭 형식 인스턴스화를 시그니처에 포함하는 제공 멤버를 만들 때는, ProvidedTypeDefinitions를 포함한 어떤 Type 인스턴스에서든 평범한 MakeArrayType, MakePointerType, MakeGenericType을 사용하면 돼요.

⚠️ Note: 어떤 경우에는 ProvidedTypeBuilder.MakeGenericType의 헬퍼를 써야 할 수도 있어요. 자세한 내용은 Type Provider SDK documentation을 참고하세요.

측정 단위(Unit of Measure) 주석 제공 (Providing Unit of Measure Annotations)

ProvidedTypes API는 측정 단위 주석을 제공하기 위한 헬퍼를 제공해요. 예를 들어 float<kg> 형식을 제공하려면 다음 코드를 쓰세요:

  let measures = ProvidedMeasureBuilder.Default
  let kg = measures.SI "kilogram"
  let m = measures.SI "meter"
  let float_kg = measures.AnnotateType(typeof<float>,[kg])

Nullable<decimal<kg/m^2>> 형식을 제공하려면 다음 코드를 쓰세요:

  let kgpm2 = measures.Ratio(kg, measures.Square m)
  let dkgpm2 = measures.AnnotateType(typeof<decimal>,[kgpm2])
  let nullableDecimal_kgpm2 = typedefof<System.Nullable<_>>.MakeGenericType [|dkgpm2 |]
프로젝트 로컬·스크립트 로컬 리소스 접근 (Accessing Project-Local or Script-Local Resources)

형식 제공자의 각 인스턴스는 생성 중에 TypeProviderConfig 값을 부여받을 수 있어요. 이 값에는 제공자의 "해석 폴더(resolution folder)"(컴파일의 프로젝트 폴더 또는 스크립트가 있는 디렉터리), 참조된 어셈블리 목록, 기타 정보가 들어 있어요.

무효화 (Invalidation)

제공자는 무효화(invalidation) 신호를 발생시켜 스키마 가정이 바뀌었을 수 있음을 F# 언어 서비스에 알릴 수 있어요. 무효화가 발생하면, 제공자가 Visual Studio에서 호스팅될 때 형식 검사(typecheck)가 다시 수행돼요. 제공자가 F# Interactive나 F# 컴파일러(fsc.exe)에서 호스팅될 때는 이 신호가 무시돼요.

스키마 정보 캐싱 (Caching Schema Information)

제공자는 스키마 정보 접근을 자주 캐싱해야 해요. 캐시된 데이터는 정적 매개변수로 주어지거나 사용자 데이터로 주어지는 파일 이름을 써서 저장해야 해요. 스키마 캐싱의 예는 FSharp.Data.TypeProviders 어셈블리의 형식 제공자들에 있는 LocalSchemaFile 매개변수예요. 이 제공자들의 구현에서 이 정적 매개변수는 형식 제공자가 네트워크를 통해 데이터 소스에 접근하는 대신 지정된 로컬 파일의 스키마 정보를 쓰도록 안내해요. 캐시된 스키마 정보를 쓰려면 정적 매개변수 ForceUpdatefalse로 설정해야 해요. 온라인·오프라인 데이터 접근을 모두 지원하도록 비슷한 기법을 쓸 수도 있어요.

지원 어셈블리 (Backing Assembly)

.dll 또는 .exe 파일을 컴파일할 때, 생성된 형식의 지원(backing) .dll 파일은 결과 어셈블리에 정적으로 연결돼요. 이 연결은 지원 어셈블리에서 IL(Intermediate Language) 형식 정의와 관리 리소스를 최종 어셈블리로 복사해서 만들어져요. F# Interactive를 쓸 때는 지원 .dll 파일이 복사되지 않고 F# Interactive 프로세스에 직접 로드돼요.

형식 제공자의 예외·진단 (Exceptions and Diagnostics from Type Providers)

제공 형식의 모든 멤버를 사용하는 모든 곳에서 예외가 발생할 수 있어요. 어떤 경우든 형식 제공자가 예외를 던지면, 호스트 컴파일러는 그 오류를 특정 형식 제공자에 귀속시켜요.

  • 형식 제공자 예외는 결코 내부 컴파일러 오류(internal compiler error)로 이어지면 안 돼요.
  • 형식 제공자는 경고를 보고할 수 없어요.
  • 형식 제공자가 F# 컴파일러, F# 개발 환경, F# Interactive에서 호스팅될 때 그 제공자의 모든 예외는 잡혀요. Message 속성이 항상 오류 텍스트이며 스택 추적은 나타나지 않아요. 예외를 던지려 한다면 System.NotSupportedException, System.IO.IOException, System.Exception 같은 예를 던질 수 있어요.
생성된 형식 제공 (Providing Generated Types)

지금까지 이 문서는 소거된 형식을 제공하는 방법을 설명했어요. F#의 형식 제공자 메커니즘은 생성된(generated) 형식을 제공하는 데도 쓸 수 있는데, 이 형식은 사용자 프로그램에 실제 .NET 형식 정의로 추가돼요. 생성된 제공 형식은 형식 정의로 참조해야 해요.

F# 3.0 릴리스의 일부인 ProvidedTypes-0.2 헬퍼 코드는 생성된 형식 제공에 대해 제한적인 지원만 해요. 생성된 형식 정의에는 다음 문이 반드시 참이어야 해요:

  • isErasedfalse로 설정되어야 한다.
  • 생성된 형식은 새로 구성된 ProvidedAssembly()에 추가되어야 한다. ProvidedAssembly()는 생성된 코드 조각의 컨테이너를 나타내요.
  • 제공자는 디스크에 대응하는 .dll 파일이 있는 실제 지원 .NET .dll 파일을 가진 어셈블리를 가져야 한다.

규칙과 제한 (Rules and Limitations)

형식 제공자를 작성할 때는 다음 규칙과 제한을 염두에 두세요.

제공 형식은 도달 가능해야 한다 (Provided types must be reachable)

모든 제공 형식은 중첩되지 않은 형식(non-nested types)에서 도달 가능해야 해요. 중첩되지 않은 형식은 TypeProviderForNamespaces 생성자 호출이나 AddNamespace 호출에서 주어져요. 예를 들어 제공자가 StaticClass.P : T 형식을 제공한다면, T가 중첩되지 않은 형식이거나 그 아래에 중첩되어 있음을 보장해야 해요.

예를 들어 어떤 제공자는 DataTypes 같은 정적 클래스에 이 T1, T2, T3, ... 형식들을 담아요. 그렇지 않으면 '어셈블리 A에서 형식 T에 대한 참조를 찾았지만 그 어셈블리에서 형식을 찾을 수 없다'는 오류가 나요. 이 오류가 나타나면 모든 하위 형식이 제공자 형식에서 도달 가능한지 확인하세요. 참고: 이 T1, T2, T3... 형식들은 on-the-fly 형식이라고 불러요. 접근 가능한 네임스페이스나 부모 형식에 넣는 것을 잊지 마세요.

형식 제공자 메커니즘의 제한 (Limitations of the Type Provider Mechanism)

F#의 형식 제공자 메커니즘에는 다음 제한이 있어요:

  • F#의 형식 제공자 기본 인프라는 **제공되는 제네릭 형식(provided generic types)**이나 **제공되는 제네릭 메서드(provided generic methods)**를 지원하지 않아요.
  • 메커니즘은 정적 매개변수를 가진 중첩 형식을 지원하지 않아요.

개발 팁 (Development Tips)

개발 과정에서 도움이 될 팁이 있어요.

Visual Studio 두 인스턴스 실행 (Run two instances of Visual Studio)

한 인스턴스에서 형식 제공자를 개발하고 다른 인스턴스에서 테스트할 수 있어요. 단, 테스트 IDE가 .dll 파일에 잠금을 걸어 형식 제공자가 다시 빌드되지 못하게 하므로, 첫 인스턴스에서 제공자를 빌드하는 동안 두 번째 인스턴스를 닫았다가, 제공자가 빌드된 후 두 번째 인스턴스를 다시 열어야 해요.

fsc.exe 호출로 형식 제공자 디버깅 (Debug type providers by using invocations of fsc.exe)

다음 도구들로 형식 제공자를 호출할 수 있어요:

  • fsc.exe (F# 명령줄 컴파일러)
  • fsi.exe (F# Interactive 컴파일러)
  • devenv.exe (Visual Studio)

형식 제공자는 테스트 스크립트 파일(예: script.fsx)에 대해 fsc.exe를 쓰는 것이 대개 가장 쉽게 디버깅돼요. 명령 프롬프트에서 디버거를 시작할 수 있어요.

devenv /debugexe fsc.exe script.fsx

stdout으로의 print 로깅(print-to-stdout logging)을 쓸 수도 있어요.

더 알아보기