教學課程:建立型別提供者

F# 中的型別提供者機制是其支援資訊豐富程式設計的重要部分。 本教學課程將逐步引導您開發數個簡單型別提供者以釐清基本概念,藉以說明如何建立您自己的型別提供者。 若要深入了解 F# 中的型別提供者機制,請參閱型別提供者

F# 生態系統包含多種型別提供者,可供常用的網際網路和企業資料服務使用。 例如:

  • FSharp.Data 包含 JSON、XML、CSV 和 HTML 文件格式的型別提供者。

  • SwaggerProvider 包含兩種產生性型別提供者,會為 OpenApi 3.0 和 Swagger 2.0 結構描述所說明的 API 產生物件模型和 HTTP 用戶端。

  • FSharp.Data.SqlClient 有一組型別提供者,用於在 F# 中檢查 T-SQL 的編譯時間內嵌。

您可以建立自訂型別提供者,也可以參考其他人建立的型別提供者。 例如,您的組織可能會有資料服務提供大量且不斷增加的具名資料集,且各資料集都有其本身的穩定資料結構描述。 您可以建立一個型別提供者,並使其以強型別方式讀取結構描述,並且對程式設計人員呈現目前的資料集。

開始之前

型別提供者機制主要用來將穩定資料和服務資訊空間導入 F# 程式設計體驗中。

此機制的用途並非是要使用與程式邏輯有關的方式,導入結構描述在程式執行期間有所變更的資訊空間。 此外,此機制也不是為了語言內部中繼程式設計而設計的;即使該領域確有某些有效的用途。 只有在需要時,以及型別提供者的開發有非常高的價值時,才適合使用此機制。

您應避免在結構描述無法使用的情況下撰寫型別提供者。 同樣地,若一般 (甚或現有的) .NET 程式庫已堪用,則應避免撰寫型別提供者。

開始之前,您可以提出下列問題:

  • 您的資訊來源是否有結構描述? 如果有,在 F# 和 .NET 型別系統中的對應為何?

  • 您可以使用現有 (動態型別) API 作為實作的起點嗎?

  • 您和組織是否會充分使用型別提供者,使其值得撰寫? 一般 .NET 程式庫是否符合您的需求?

  • 您的結構描述會有多少變更?

  • 在編碼期間是否會變更?

  • 在編碼工作階段之間是否會變更?

  • 在程式執行期間是否會變更?

結構描述在執行階段和已編譯程式碼的存留期內都很穩定,是型別提供者最適用的狀態。

簡單型別提供者

此範例是 Samples.HelloWorldTypeProvider,類似於 F# 型別提供者 SDKexamples 目錄中的範例。 此提供者會提供包含 100 個清除型別的「型別空間」,如下列程式碼所示,其間使用 F# 簽章語法,並省略了 Type1 以外所有項目的詳細資料。 如需清除型別的詳細資訊,請參閱本主題稍後的關於清除已提供型別的詳細資料

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()

若要使用此提供者,請開啟個別的 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# 互動執行個體。 否則將會發生建置錯誤,因為輸出 DLL 會遭到鎖定。

若要使用 print 陳述式對此提供者進行偵錯,請建立指令碼將提供者的問題公開,然後使用下列程式碼:

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

若要使用 Visual Studio 對此提供者進行偵錯,請使用系統管理認證開啟 Visual Studio 的開發人員命令提示字元,然後執行下列命令:

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

或者,開啟 Visual Studio、開啟 [偵錯] 功能表、選擇 Debug/Attach to process…,然後附加至另一個 devenv 程序以在該處編輯指令碼。 藉由使用此方法,您可透過互動方式將運算式輸入第二個執行個體 (具有完整的 IntelliSense 和其他功能) 中,而更輕鬆地將型別提供者中的特定邏輯設為目標。

您可以停用 Just My Code 偵錯,以在產生的程式碼中更清楚地識別錯誤。 如需如何啟用或停用此功能的資訊,請參閱使用偵錯工具瀏覽程式碼。 此外,您也可以開啟 Debug 功能表,然後選擇 Exceptions 或選擇 Ctrl+Alt+E 鍵將 Exceptions 對話方塊開啟,以設定成要攔截第一個可能發生的例外狀況。 在該對話方塊的 Common Language Runtime Exceptions 底下,選取 Thrown 核取方塊。

型別提供者的實作

本節將逐步引導您完成型別提供者實作的主體區段。 首先,您會定義自訂型別提供者本身的型別:

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

此型別必須是公用的,且您必須使用 TypeProvider 屬性加以標記,如此,當個別的 F# 專案參考包含型別的組件時,編譯器將可辨識型別提供者。 config 參數是選用的,如果存在,會包含 F# 編譯器所建立的型別提供者執行個體的內容組態資訊。

接著,您會實作 ITypeProvider 介面。 在此案例中,您會使用 ProvidedTypes API 中的 TypeProviderForNamespaces 型別作為基底型別。 此協助程式型別可提供一組有限而積極提供的命名空間,每個命名空間都直接包含有限的固定數目、積極提供的型別。 在此內容中,提供者會積極產生型別,即使不需要或未使用型別亦然。

inherit TypeProviderForNamespaces(config)

接下來,定義本機私人值為提供的型別指定命名空間,並尋找型別提供者組件本身。 此組件後續將作為清除已提供型別的邏輯父型別。

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

接著,建立函式以提供各個型別:Type1…Type100。 本主題稍後將進一步詳細說明此函式。

let makeOneProvidedType (n:int) = …

然後,產生 100 個提供的型別:

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

接下來,將型別新增為提供的命名空間:

do this.AddNamespace(namespaceName, types)

最後,新增組件屬性,以指出您要建立型別提供者 DLL:

[<assembly:TypeProviderAssembly>]
do()

提供一個型別及其成員

makeOneProvidedType 函式會執行提供其中一種型別的實際工作。

let makeOneProvidedType (n:int) =
…

下列步驟說明此函式的實作。 首先,建立提供的型別 (例如,n = 1 時為 Type1、n = 57 時為 Type57)。

// 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>)

您應注意以下幾點:

  • 這個提供的型別會清除。 由於您指出基底型別是 obj,因此執行個體會在已編譯的程式碼中顯示為 obj 型別的值。

  • 指定了非巢狀型別時,您必須指定組件和命名空間。 如果是清除型別,則組件應為型別提供者組件本身。

接著,將 XML 文件新增至型別。 此文件是延遲的,也就是說,主機編譯器需要時就會隨需計算。

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

接下來,您會將提供的靜態屬性新增至型別:

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

取得此屬性一律會評估為字串 "Hello!"。 屬性的 GetterCode 會使用 F# 引用,代表主機編譯器為了取得屬性而產生的程式碼。 如需引用的詳細資訊,請參閱程式碼引用 (F#)

將 XML 文件新增至屬性。

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

現在,將提供的屬性附加至提供的型別。 您必須將提供的成員附加至單一型別。 否則將無法存取成員。

t.AddMember staticProp

現在,建立提供的建構函式,且不採用任何參數。

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

建構函式的 InvokeCode 會傳回 F# 引用,代表主機編譯器在呼叫建構函式時產生的程式碼。 例如,您可以使用下列建構函式:

new Type10()

已提供型別的執行個體將會使用基礎資料「物件資料」來建立。 引用的程式碼會包含 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# 引用,代表主機編譯器為了呼叫方法而產生的程式碼。 例如,您可以使用下列建構函式:

new Type10("ten")

已提供型別的執行個體會使用基礎資料 "ten" 來建立。 您可能已注意到 InvokeCode 函式會傳回引用。 此函式的輸入是運算式清單,每個建構函式參數各有一個。 在此案例中,可在 args[0] 中使用代表單一參數值的運算式。 呼叫建構函式的程式碼會將傳回值強制轉型為清除型別 obj。 將第二個提供的建構函式新增至型別之後,您會建立提供的執行個體屬性:

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

取得此屬性將會傳回字串的長度,此為表示法物件。 GetterCode 屬性會傳回 F# 引用,指定主機編譯器產生用以取得屬性的程式碼。 如同 InvokeCodeGetterCode 函式會傳回引用。 主機編譯器會使用引數清單呼叫此函式。 在此案例中,引數僅包含單一運算式,代表您要對其呼叫 getter 的執行個體,可以使用 args[0] 來存取。 接著,GetterCode 的實作會接合到清除型別 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 個巢狀屬性的巢狀型別。 此巢狀型別及其屬性會延遲建立,也就是進行隨需計算。

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])

清除已提供型別的詳細資料

本節中的範例僅提供清除已提供型別,這在下列情況下特別有用:

  • 當您為僅包含資料和方法的資訊空間撰寫提供者時。

  • 您在撰寫提供者,而正確的執行階段型別語意對於資訊空間的實際使用並不重要。

  • 您在撰寫資訊空間的提供者,而空間龐大且互連,因此在技術上無法為資訊空間產生實際的 .NET 型別。

在此範例中,每個提供的型別都會清除為型別 obj,而且所有的型別使用在已編譯的程式碼中都會顯示為型別 obj。 事實上,這些範例中的基礎物件是字串,但型別在 .NET 編譯的程式碼中會顯示為 System.Object。 如同型別清除的各種用法,您可以使用明確的 boxing、unboxing 和轉換來推翻清除型別。 在此情況下,使用物件時可能會產生無效的轉換例外狀況。 提供者執行階段可定義本身的私人表示型別,以利防範錯誤的表示法。 您無法在 F# 本身定義清除型別。 只有已提供的型別才可清除。 您必須了解對型別提供者或提供清除型別的提供者使用清除型別,實際上和語意上會有何後果。 清除型別沒有實際的 .NET 型別。 因此,您無法精確反映型別,且如果使用執行階段轉換和其他依賴確切執行階段型別語意的技術,您可能會推翻清除型別。 清除型別的推翻常會導致執行階段的型別轉換例外狀況。

選擇清除已提供型別的表示法

清除已提供型別的某些用法並不需要表示法。 例如,清除已提供型別可能僅包含靜態屬性和成員而沒有建構函式,而且沒有方法或屬性會傳回型別的執行個體。 如果您可以連線到清除已提供型別的執行個體,則必須考量下列問題:

清除已提供的型別是何意?

  • 清除已提供的型別,型別才會出現在已編譯的 .NET 程式碼中。

  • 清除已提供的清除類別型別,一律是型別繼承鏈結中的第一個非清除基底型別。

  • 清除已提供的清除介面型別一律為 System.Object

已提供型別的表示法是何意?

  • 清除已提供型別可能的物件集,稱為其表示法。 在本文件的範例中,所有清除已提供型別 Type1..Type100 的表示法一律為字串物件。

提供的型別所有的表示法都必須與已提供型別的清除相容。 (否則,F# 編譯器會針對型別提供者的使用提供錯誤,或會產生無效的無法驗證的 .NET 程序碼。如果型別提供者傳回的程式碼提供了無效的表示法,則該型別提供者無效。)

您可以使用下列兩種方法之一來選擇已提供物件的表示法;兩種方法都很常見:

  • 如果您只是對現有的 .NET 型別提供強型別包裝函式,則讓您的型別清除為該型別,並且 (或) 使用該型別的執行個體作為表示法,應該會有好處。 如果該型別大部分的現有方法在使用強型別版本時仍然有利,此方法就很合用。

  • 如果您想要建立與任何現有 .NET API 明顯不同的 API,可以建立執行階段型別,並且使其成為已提供型別的型別清除和表示法。

本文件中的範例會使用字串作為已提供物件的表示法。 表示法往往適合使用其他物件。 例如,您可以使用字典作為屬性包:

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() @@>), …)

主要課題

上一節說明了如何建立會提供各種型別、屬性和方法的簡單清除型別提供者。 本節也說明了型別清除的概念,包括從型別提供者提供清除型別的一些優缺點,並討論了清除型別的表示法。

使用靜態參數的型別提供者

以靜態資料將型別提供者參數化的能力可實現許多有趣的案例,即使在提供者不需要存取任何本機或遠端資料的情況下,也是如此。 在本節中,您將了解整合這類提供者的一些基本技術。

型別檢查 Regex 提供者

假設您想要為規則運算式實作型別提供者,將 .NET Regex 程式庫包裝在提供下列編譯時期保證的介面中:

  • 驗證規則運算式是否有效。

  • 對以規則運算式中的任何群組名稱為基礎的相符項目提供具名屬性。

本節說明如何使用型別提供者建立 RegexTyped 型別,讓規則運算式模式加以參數化而提供這些效益。 如果提供的模式無效,編譯器將會報告錯誤,且型別提供者可從模式中擷取群組,以便您可以使用相符項目的具名屬性加以存取。 在設計型別提供者時,您應該考量其公開 API 對終端使用者呈現的外觀,以及此設計如何轉譯為 .NET 程式碼。 下列範例說明如何使用這類 API 取得區碼的元件:

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 集合使用索引子。

下列程式碼是實作這類提供者的邏輯核心;此範例並未示範如何將所有成員新增至提供的型別。 如需每個新增成員的相關資訊,請參閱本主題稍後的適當章節。

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 回呼內,您可以定義將在提供引數後傳回的型別。

  • 此程式碼會將 HideObjectMethods 設定為 true,讓 IntelliSense 體驗保持精簡。 此屬性會使已提供物件的 IntelliSense 清單隱藏 EqualsGetHashCodeFinalizeGetType 成員。

  • 您使用 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

先前的程式碼定義了方法 IsMatch,此方法用字串作為輸入,並且傳回 bool。 唯一棘手的部分,是在 InvokeCode 定義中使用 args 引數。 在此範例中,args 是代表此方法之引數的引用清單。 如果方法是執行個體方法,則第一個引數代表 this 引數。 但對靜態方法而言,引數只是方法的明確引數。 請注意,引用值的型別應符合指定的傳回型別 (在此案例中為 bool)。 另請注意,此程式碼會使用 AddXmlDoc 方法來確定提供的方法也有實用的文件 (可以透過 IntelliSense 提供)。

接著,新增執行個體 Match 方法。 不過,此方法應傳回提供的 Match 型別值,以便用強型別的方式存取群組。 因此,您會先宣告 Match 型別。 由於此型別相依於提供作為靜態引數的模式,此型別必須內嵌在參數化型別定義內:

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

ty.AddMember matchTy

接著,對每個群組的 Match 型別新增一個屬性。 在執行階段,相符項目會以 Match 值表示,因此定義屬性的引用必須使用 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 執行個體,而此執行個體會再次 Boxed 到某個物件,因為 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 ()

主要課題

本節說明如何建立對本身的靜態參數運作的型別提供者。 提供者會檢查靜態參數,並根據其值提供作業。

受本機資料支援的型別提供者

您很可能會希望型別提供者不僅根據靜態參數來呈現 API,也會依據來自本機或遠端系統的資訊。 本節討論以本機資料 (例如本機資料檔案) 為基礎的型別提供者。

簡單 CSV 檔案提供者

在簡單的範例中,我們假設有一個型別提供者用來存取逗號分隔值 (CSV) 格式的科學資料。 本節假設 CSV 檔案包含一個尾隨浮點數資料的標頭資料列,如下表所示:

距離 (公尺) 時間 (秒)
50.0 3.7
100.0 5.2
150.0 6.4

本節說明如何提供型別,讓您用來取得具有型別 Distancefloat<meter> 屬性的資料列,以及具有型別 float<second>Time 屬性的資料列。 為了簡單起見,我們做了下列假設:

  • 標頭名稱沒有單位,或採用「名稱 (單位)」的格式,且不含逗號。

  • 單位全都是 System International (SI) 單位,如 FSharp.Data.UnitSystems.SI.UnitNames Module (F#) 模組所定義。

  • 單位都是簡單的 (例如公尺),而不是複合的 (例如公尺/秒)。

  • 所有資料行都包含浮點數資料。

更完整的提供者會放寬這些限制。

同樣地,第一個步驟是考量 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,CSV 檔案將會在 Visual Studio 中開啟。

  • 您可以使用 ProvidedMeasureBuilder 型別來查閱 SI 單位,並產生相關的 float<_> 型別。

主要課題

本節說明如何使用包含在資料來源本身的簡單結構描述,為本機資料來源建立型別提供者。

更進一步

以下幾節包含進一步研究的建議。

了解清除型別的已編譯程式碼

為了讓您了解型別提供者的用法與發出的程式碼之間的對應情形,請使用本主題中先前使用的 HelloWorldTypeProvider 查看下列函式。

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

下圖是使用 ildasm.exe 反向組譯所產生的程式碼:

.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

如範例所示,所有對型別 Type1InstanceProperty 屬性的引述均已清除,僅保留相關執行階段型別的作業。

型別提供者的設計和命名慣例

撰寫型別提供者時請觀察下列慣例。

連線通訊協定的提供者 一般而言,資料與服務連線通訊協定 (例如 OData 或 SQL 連線) 的多數提供者 DLL 的名稱都應以 TypeProviderTypeProviders 結尾。 例如,請使用類似下列字串的 DLL 名稱:

Fabrikam.Management.BasicTypeProviders.dll

請確定您提供的型別是對應命名空間的成員,並指出您實作的連線通訊協定:

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

一般編碼的公用程式提供者。 對於公用程式型別提供者 (例如規則運算式的型別提供者),型別提供者可以是基底程式庫的一部分,如下列範例所示:

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

在此情況下,提供的型別會根據一般 .NET 設計慣例出現在適當的點:

  open Fabrikam.Core.Text.RegexTyped

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

單一資料來源。 某些型別提供者會連線至單一專用資料來源,並且僅提供資料。 在此情況下,您應卸除 TypeProvider 尾碼,並使用一般慣例進行 .NET 命名:

#r "Fabrikam.Data.Freebase.dll"

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

如需詳細資訊,請參閱本主題稍後說明的 GetConnection 設計慣例。

型別提供者的設計模式

以下幾節說明您在撰寫型別提供者時可使用的設計模式。

GetConnection 設計模式

大部分型別提供者都應撰寫為使用 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

由遠端資料和服務支援的型別提供者

建立由遠端資料和服務支援的型別提供者之前,您必須先考量連線程式設計固有的一系列問題。 這些問題包含下列考量:

  • 結構描述對應

  • 結構描述有所變更時的活躍度和失效

  • 結構描述快取

  • 資料存取作業的非同步實作

  • 支援查詢,包括 LINQ 查詢

  • 認證和驗證

本主題不會進一步探索這些問題。

其他撰寫技術

您在撰寫自己的型別提供者時可以使用下列其他技術。

隨需建立型別和成員

ProvidedType API 具有延遲版的 AddMember。

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

這些版本可用來建立型別的隨需空間。

提供陣列型別和泛型型別具現化

您對 Type 的任何執行個體 (包括 ProvidedTypeDefinitions) 使用一般 MakeArrayTypeMakePointerTypeMakeGenericType,以建立提供的成員 (其簽章包括陣列型別、byref 型別,以及泛型型別的具現化)。

注意

在某些情況下,您可能必須在 ProvidedTypeBuilder.MakeGenericType 中使用協助程式。 如需詳細資訊,請參閱型別提供者 SDK 文件

提供測量單位註釋

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 |]

存取專案本機或指令碼本機資源

型別提供者的每個執行個體在建構期間都可獲得一個 TypeProviderConfig 值。 此值包含提供者的「解析資料夾」(也就是編譯的專案資料夾或包含指令碼的目錄)、參考的組件清單,以及其他資訊。

失效

提供者可以引發無效信號,以通知 F# 語言服務結構描述假設可能已變更。 無效發生時,如果提供者裝載於 Visual Studio 中,就會重新執行型別檢查。 提供者裝載於 F# 互動或 F# 編譯器 (fsc.exe) 時,將會忽略此信號。

快取結構描述資訊

提供者常須快取結構描述資訊的存取。 快取的資料應使用指定為靜態參數或使用者資料的檔案名稱來儲存。 舉例來說,FSharp.Data.TypeProviders 組件中型別提供者的 LocalSchemaFile 參數,即為結構描述快取。 在這些提供者的實作中,此靜態參數會指示型別提供者使用指定本機檔案中的結構描述資訊,而不是透過網路存取資料來源。 若要使用快取的結構描述資訊,您也必須將靜態參數 ForceUpdate 設定為 false。 您可以使用類似的技術來啟用線上和離線資料存取。

支援組件

當您編譯 .dll.exe 檔案時,產生的型別適用的支援 .dll 檔案會靜態連結至產生的組件中。 此連結的建立方式,是將中繼語言 (IL) 型別定義和任何受控資源從支援組件複製到最終組件中。 當您使用 F# 互動時,支援 .dll 檔案不會複製,而是直接載入 F# 互動程序中。

型別提供者的例外狀況和診斷

以任何方式使用已提供型別的任何成員,都有可能擲回例外狀況。 在任何情況下,當型別提供者擲回例外狀況時,主機編譯器就會將錯誤歸因於特定型別提供者。

  • 型別提供者例外狀況絕不會產生內部編譯器錯誤。

  • 型別提供者無法報告警告。

  • 當型別提供者裝載於 F# 編譯器、F# 開發環境或 F# 互動時,將會攔截該提供者的所有例外狀況。 Message 屬性一律為錯誤文字,且不會顯示堆疊追蹤。 如果您要擲回例外狀況,可以擲回下列範例:System.NotSupportedExceptionSystem.IO.IOExceptionSystem.Exception

提供產生的型別

到目前為止,本文件說明了如何提供清除型別。 您也可以使用 F# 中的型別提供者機制來提供產生的型別,這些型別會在使用者的程式中新增為實際的 .NET 型別定義。 您必須使用型別定義來參考產生的已提供型別。

open Microsoft.FSharp.TypeProviders

type Service = ODataService<"http://services.odata.org/Northwind/Northwind.svc/">

F# 3.0 版包含的 ProvidedTypes-0.2 協助程式程式碼只能有限度地支援提供產生的型別。 對於產生的型別定義,下列陳述必須屬實:

  • isErased 必須設定為 false

  • 產生的型別必須新增至新建構的 ProvidedAssembly(),這代表產生的程式碼片段的容器。

  • 提供者必須有一個組件,具有與磁碟上的 .dll 檔案相符的實際支援 .NET .dll 檔案。

規則和限制

在撰寫型別提供者時,請留意下列規則和限制。

提供的型別必須是可連線的

所有提供的型別都應可從非巢狀型別連線。 在呼叫 TypeProviderForNamespaces 建構函式或呼叫 AddNamespace 時,會提供非巢狀型別。 例如,如果提供者提供了型別 StaticClass.P : T,您必須確定 T 是非巢狀型別還是內嵌於其下的型別。

例如,某些提供者具有靜態類別,例如包含 T1, T2, T3, ... 等型別的 DataTypes。 否則,會有錯誤指出在組件 A 中可找到型別 T 的參考,但該組件中找不到該型別。 如果出現此錯誤,請確認您可以從提供者型別連線到所有子型別。 注意:這些 T1, T2, T3... 型別稱為即時型別。 請記得將其放在可存取的命名空間或父型別中。

型別提供者機制的限制

F# 中的型別提供者機制有下列限制:

  • F# 中型別提供者的基礎結構不支援提供的泛型型別或提供的泛型方法。

  • 此機制不支援具有靜態參數的巢狀型別。

開發秘訣

在開發過程中,下列秘訣可能會有所幫助:

執行兩個 Visual Studio 執行個體

您可以在一個執行個體中開發型別提供者,並在另一個執行個體中測試提供者,因為測試 IDE 會鎖定 .dll 檔案,以防止重建型別提供者。 因此,在 Visual Studio 的第一個執行個體中建置提供者時,您必須關閉第二個執行個體,然後必須在提供者建置後重新開啟第二個執行個體。

使用 fsc.exe 的叫用對型別提供者進行偵錯

您可以使用下列工具來叫用型別提供者:

  • fsc.exe (F# 命令列編譯器)

  • fsi.exe (F# 互動編譯器)

  • devenv.exe (Visual Studio)

對測試指令碼檔案 (例如 script.fsx) 使用 fsc.exe,通常最容易對型別提供者進行偵錯。 您可以從命令提示字元啟動偵錯工具。

devenv /debugexe fsc.exe script.fsx

您可以使用「列印到 stdout」記錄。

另請參閱