65.9K
CodeProject 正在变化。 阅读更多。
Home

使用 F# 进行嵌入式脚本

starIconstarIconstarIconstarIconstarIcon

5.00/5 (5投票s)

2011年8月22日

CPOL

6分钟阅读

viewsIcon

55321

downloadIcon

1096

本文介绍如何在运行时编译和运行 F# 代码。

引言

在本文中,我将介绍如何使用 F# 脚本来扩展应用程序的功能。有时,需要用一些自定义行为来扩展现有应用程序的功能。如果您不想或无法每次都需要时都经过应用程序开发和构建周期,那么脚本编写可能是一个不错的解决方案。它能以最小的努力根据特定需求定制您的应用程序。

由于 F# 是 .NET 中完全受支持的语言,因此将其脚本功能集成到 .NET 应用程序中非常简单自然。我将向您展示如何将 F# 脚本添加到 Windows Forms 应用程序中。

附带的示例应用程序有两个主要部分:

  1. 使用 C# 编写的宿主程序。这个简单的 Windows Forms 应用程序构建在 FSCompileTestForm 项目中。
  2. 使用 F# 编写的嵌入式扩展,设计为一个类库。它构建在 FSExecutor 项目中。

整个应用程序是针对 .NET Framework 4.0 构建的。使用的 F# 编译器来自 F# PowerPack 包

下面的屏幕截图展示了运行带有复数算术的简单脚本的示例。

Sample application screenshot.

准备工作

嵌入式脚本需要与宿主应用程序交换数据。这可以通过使用共享的通用组件或数据流来实现。有标准的输入/输出/错误流。利用简单的 printfn 函数在脚本程序中使用它们会很方便。在示例程序中,标准输出和错误流被重定向到下方的文本区域。此功能实现在 StdStreamRedirector 模块中。该模块有一个 static 方法 Init ,它接受两个操作并返回一个 Redirector 对象。Redirector 对象实现了 IDisposable 接口。当它被释放时,本机流句柄将恢复到其原始状态。

let Init(stdOutAction: Action<string>, stdErrAction: Action<string>) = 
    new Redirector(stdOutAction, stdErrAction)

提供的操作分别用于处理来自标准输出和标准错误流的数据。这些操作可以对提供的数据进行任何处理。这取决于它们的实现。我选择将传入的数据以黑色字体放入 stdout 数据,以红色字体放入 stderr 数据到文本区域。

在 .NET 应用程序中,标准流在第一次使用时初始化,并一直保持初始化状态。因此,应尽快在任何使用 Console.Out Console.Error 流或任何 printf/eprintf 函数调用之前完成标准流的初始化。我将此代码放在主窗体的 Load 事件回调中。

private void MainForm_Load(object sender, EventArgs e)
{
    Action<string> aOut = (text => AppendStdStreamText(text, Color.Black));
    Action<string> aErr = (text => AppendStdStreamText(text, Color.DarkRed));
    m_r = StdStreamRedirector.Init(aOut, aErr);
}

Redirector 对象 m_r 应在 .NET 标准流使用完毕后释放。因此,其释放被放在主窗体的 Dispose 方法中。

protected override void Dispose(bool disposing)
{
    if (disposing && (components != null))
    {
        ((System.IDisposable)m_r).Dispose();
        m_r = null;

        components.Dispose();
    }
    base.Dispose(disposing);
}

Redirector 对象是用 F# 实现的,F# 始终显式实现接口,因此要能够调用 Redirector 对象的 Dispose() 方法,必须将其显式转换为 IDisposable 接口。

在内部,Redirector 对象使用与标准流连接的 AnonymousPipeServerStream 对象。为此,使用了 Kernel32.dll 中的 SetStdHandle GetStdHandle 函数。

module private Win32Interop = 
    [<DllImport("Kernel32.dll")>]
    extern [<marshalas(unmanagedtype.bool)>] 
		bool SetStdHandle(UInt32 nStdHandle, IntPtr hHandle)
    [<DllImport("Kernel32.dll")>]
    extern IntPtr GetStdHandle(UInt32 nStdHandle)

type Redirector(out: Action<string>, err: Action<string>) = 
    let stdOutHandleId = uint32(-11)
    let stdErrHandleId = uint32(-12)
    let pipeServerOut = new AnonymousPipeServerStream(PipeDirection.Out)
    let pipeServerErr = new AnonymousPipeServerStream(PipeDirection.Out)

    do if not(Win32Interop.SetStdHandle
	(stdOutHandleId, pipeServerOut.SafePipeHandle.DangerousGetHandle())) 
        then failwith "Cannot set handle for stdout."
    do if not(Win32Interop.SetStdHandle
	(stdErrHandleId, pipeServerErr.SafePipeHandle.DangerousGetHandle())) 
        then failwith "Cannot set handle for stderr."
    do if not(ThreadPool.QueueUserWorkItem
	(fun o -> readPipe(pipeServerOut.ClientSafePipeHandle, out))) 
        then failwith "Cannot run listner thread."
    do if not(ThreadPool.QueueUserWorkItem
	(fun o -> readPipe(pipeServerErr.ClientSafePipeHandle, err))) 
        then failwith "Cannot run listner thread."

应用程序线程池中的两个独立线程从连接到标准流的管道读取数据,并将数据发送到提供的操作。为简单起见,这些线程中的所有异常都被忽略。

let private readPipe (h: SafePipeHandle, a: Action<string>) = 
    use clientPipeStream = new AnonymousPipeClientStream(PipeDirection.In, h)
    use reader = new StreamReader(clientPipeStream)
    try
        while not(reader.EndOfStream) do
            let s = reader.ReadLine()
            a.Invoke(s)
    with
        | ex -> ()

此实现使输出和错误流完全独立,并考虑到线程池线程的延迟,printf/eprintf 的输出可能会无序。来自线程池线程的数据进入属于 GUI 线程的 Windows Forms 文本区域。但是,线程池中调用的操作不能直接访问 GUI 组件,它们必须通过 GUI 线程中的 Contorl.Invoke 方法进行。首先,最好通过检查 InvokeRequired 属性来检查是否确实需要 Invoke 调用。如果需要,则使用通过 Invoke 方法传递的匿名委托调用追加彩色文本。Invoke 方法和 InvokeRequired 属性是线程安全的,因此可以从线程池线程调用它们。不需要额外的同步。

private void AppendStdStreamText(string text, Color c)
{
    if(this.InvokeRequired)
    {
        MethodInvoker del = delegate
        {
            AppendStdStreamText(text, c);
        };
        this.Invoke(del);
        return;
    }
    AppendColoredText(text, c);
    m_outputTB.AppendText("\n");
}
private void AppendColoredText(string text, Color c)
{
    int l1 = m_outputTB.TextLength;
    m_outputTB.AppendText(text);
    int l2 = m_outputTB.TextLength;
    m_outputTB.SelectionStart = l1;
    m_outputTB.SelectionLength = l2 - l1;
    m_outputTB.SelectionColor = c;
}

AppendColoredText 方法将提供的文本添加到控件并设置所需的颜色。

以下是不同流的彩色文本的屏幕截图。

Colored standard output and error streams.

脚本编译和执行

现在,所有准备工作都已完成,是时候将脚本文本编译成可执行程序集了。为此,使用了 F# PowerPack 包中的 F# 编译器。所有编译和执行功能都实现在 FSExecutor 模块中。对于编译,提供了单个 string 中的脚本代码以及 seq<string>(F#) 中的引用程序集列表(对应 C# 中的 IEnumerable<string>)。可以使用绝对程序集路径或 GAC 注册程序集的 DLL 名称来设置引用程序集。

首先必须创建 F# 编译器对象。然后,将不同的编译参数设置到 CompilerParameters 对象。引用程序集的序列也设置到编译器参数中。

由于脚本通常不是一个大型程序,因此为了加速整个过程,**结果程序集是在内存中生成的**,因此将 GenerateInMemory 属性设置为 true。脚本程序不是类库,并且应该有一个类型为 MethodInfo 的单个 EntryPoint 属性,因此 GenerateExecutable 属性也应设置为 true

let compile (code: string) references = 
    let compiler = new FSharpCodeProvider()
    let cp = new System.CodeDom.Compiler.CompilerParameters()
    for r in references do cp.ReferencedAssemblies.Add(r) |> ignore done
    cp.GenerateInMemory <- true
    cp.GenerateExecutable <- true
    let cr = compiler.CompileAssemblyFromSource(cp, code)
    (cr.CompiledAssembly, cr.Output, cr.Errors)

compile 函数返回一个元组,其中包含已编译的程序集以及编译输出和错误消息。如果存在错误消息,它们将被打印到标准错误流,脚本执行将结束。

Compilation failed.

如果没有错误消息,则所有输出消息都将打印到标准输出流,并执行生成的程序集。

let CompileAndExecute(code: string, references: seq<string>) = 
    let sw = new Stopwatch()
    sw.Start()
    let (assembly, output, errors) = compile code references
    if errors.Count > 0 then
        for e in errors do eprintfn "%s" (e.ToString()) done
    else
        for o in output do printfn "%s" o done
        executeAssembly assembly
    sw.Stop()
    printfn "%s %i milliseconds." "Compile and execute takes" sw.ElapsedMilliseconds

使用 Stopwatch 对象来测量编译和执行的总时间。脚本完成后,还将打印总执行时间。

executeAssembly 函数用于实际的已编译程序集执行。

let executeAssembly (a: Assembly) = 
    try
        a.EntryPoint.Invoke(null, null) |> ignore
        printfn "Execution successfully completed."
    with
        | :? TargetInvocationException as tex -> eprintfn 
		"Execution failed with: %s" (tex.InnerException.Message)
        | ex -> eprintfn "Execution cannot start, reason: %s" (ex.ToString())

使用 EntryPoint 属性来运行脚本。这大大简化了编写脚本,因为不需要将代码包装在类或模块中。

脚本的所有运行时错误都捕获在 TargetInvacationException 类中,并与所有其他错误区分开处理,以便将脚本错误与宿主程序的错误区分开。

脚本执行优化

正如您所见,即使是微小的脚本,第一次执行也需要很长时间。在第一个屏幕截图中需要 1713 毫秒。缓慢的原因是编译。F# 编译器非常智能,它需要一些时间来识别所有被省略的类型并执行必要的优化。但是,如果同一个脚本运行多次,最好只编译一次,然后根据需要执行任意多次。为此,CompileAndExecute 函数使用 CompiledAssemblies 字典来映射脚本代码和已编译的程序集。如果为提供的代码在字典中找到了已编译的程序集,则执行该程序集,而不会进行额外的编译。但是脚本可能相当长,所以最好不要使用文本本身,而是使用它的哈希值。MD5 哈希算法用于比较脚本代码。它不提供完全可靠的比较,但对于大多数实际解决方案,发生哈希冲突的概率可以忽略不计。下面提供了修改过的带有 MD5 计算的 CompileAndExecute 函数。

let getMd5Hash (code: string) = 
    let md5 = MD5.Create()
    let codeBytes = Encoding.UTF8.GetBytes(code)
    let hash = md5.ComputeHash(codeBytes)
    let sb = new StringBuilder()
    for b in hash do sb.Append(b.ToString("x2")) |> ignore done
    sb.ToString()

let CompileAndExecute(code: string, references: seq<string>) = 
    let sw = new Stopwatch()
    sw.Start()
    let hash = getMd5Hash code
    if CompiledAssemblies.ContainsKey(hash) then
        executeAssembly CompiledAssemblies.[hash]
    else
        let (assembly, output, errors) = compile code references
        if errors.Count > 0 then
            for e in errors do eprintfn "%s" (e.ToString()) done
        else
            for o in output do printfn "%s" o done
            executeAssembly assembly
            CompiledAssemblies.Add(hash, assembly)
    sw.Stop()
    printfn "%s %i milliseconds." "Compile and execute takes" sw.ElapsedMilliseconds

正如您在屏幕截图中看到的,使用缓存的已编译程序集的后续执行速度极快。这些执行耗时不到 1 毫秒,秒表显示为 0 毫秒。

结论

F# 脚本非常方便,因为 F# 语言简洁,执行性能相当好,可以与其他高性能的 C#、Java 甚至 C++ 解决方案相媲美。此外,F# 可以完美地与任何 .NET 环境集成,利用各种高质量的 .NET 库。

然而,F# 本身的编译速度相当慢。如果脚本代码变化很大,并且每次代码版本的执行都没有重复,那么纯动态语言(例如 Python)具有快速编译阶段会更合适。它们的执行性能和类型安全性不如 F#,但考虑到编译时间,整体性能可能更好。

© . All rights reserved.