教程:创建类型提供程序

F# 中的类型提供程序机制是它支持信息丰富的编程的重要部分。 本教程通过引导你开发几个体现基本概念的简单类型提供程序,来说明如何创建自己的类型提供程序。 有关 F# 中的类型提供程序机制详细信息,请参阅类型提供程序

F# 生态系统包含一系列面向常用 Internet 和企业数据服务的类型提供程序。 例如:

  • 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 和其他功能),从而更轻松地以类型提供程序中的特定逻辑为目标。

可以禁用“仅我的代码”调试以更好地识别所生成代码中的错误。 有关如何启用或禁用此功能的信息,请参阅使用调试器浏览代码。 此外,还可通过打开“Debug”菜单,然后选择“Exceptions”,或是选择 Ctrl+Alt+E 键打开“Exceptions”对话框来设置最可能的异常捕获。 在此对话框中的 Common Language Runtime Exceptions 下,选中 Thrown 复选框。

类型提供程序的实现

本部分会引导你完成类型提供程序实现的主体部分。 首先,定义自定义类型提供程序本身的类型:

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

此类型必须是公共的,并且你必须使用 TypeProvider 属性进行标记,以便当单独 F# 项目引用包含该类型的程序集时,编译器会识别类型提供程序。 config 参数是可选的,如果存在,则包含 F# 编译器创建的类型提供程序实例的上下文配置信息。

接下来,实现 ITypeProvider 接口。 在此例中,使用 TypeProviderForNamespaces API 中的 ProvidedTypes 类型作为基类型。 此帮助程序类型可以提供包含预先提供命名空间的有限集合,每个命名空间直接包含有限数量的固定预先提供类型。 在此上下文中,提供程序会预先生成类型,即使不需要或不使用这些类型。

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

使用基础数据“The object data”创建所提供类型的实例。 所引用代码包括到 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# 引用,该引用指定主机编译器为获取属性而生成的代码。 与 InvokeCode 一样,GetterCode 函数会返回引用。 主机编译器会使用参数列表调用此函数。 在此例中,参数仅包含表示对其调用 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。 与类型擦除的所有用法一样,可以使用显式装箱、取消装箱和强制转换来颠覆擦除类型。 在这种情况下,使用对象时可能会导致无效的强制转换异常。 提供程序运行时可以定义自己的专用表示形式类型,以帮助防范错误的表示形式。 无法在 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() @@>), …)

主要课程

上一部分说明了如何创建提供一系列类型、属性和方法的简单擦除类型提供程序。 本部分还说明了类型擦除的概念(包括从类型提供程序提供擦除类型的一些优点和缺点),并讨论了擦除类型的表示形式。

使用静态参数的类型提供程序

通过静态数据参数化类型提供程序的能力可以实现许多有趣的方案,即使是提供程序不需要访问任何本地或远程数据的情况。 在本部分,你将了解一些用于将此类提供程序放在一起的基本技术。

类型检查正则表达式提供程序

假设要为正则表达式实现一个类型提供程序,该提供程序将 .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"

请注意以下几点:

  • 标准正则表达式类型表示参数化 RegexTyped 类型。

  • RegexTyped 构造函数导致调用正则表达式构造函数,从而传入模式的静态类型参数。

  • 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(可选,因为提供了默认值)。

  • 提供静态参数后,会创建正则表达式的实例。 此实例会在正则表达式格式错误时引发异常,并且会向用户报告此错误。

  • 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 正则表达式实例的创建,这会再次装箱到对象,因为 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

本部分演示如何提供可用于获取通过类型 float<meter>Distance 属性和类型 float<second>Time 属性获取行的类型。 为简单起见,进行了以下假设:

  • 标题名称没有单位或格式为“名称(单位)”,并且不包含逗号。

  • 单位全部是系统国际 (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 的任何实例使用正常 MakeArrayTypeMakePointerTypeMakeGenericType(包括 ProvidedTypeDefinitions),来创建所提供成员(其签名包括数组类型、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(),它表示生成的代码片段的容器。

  • 提供程序必须具有一个程序集,该程序集具有实际支持 .NET .dll 文件,而该文件在磁盘上具有匹配 .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 日志记录。

另请参阅