使用 F# 进行嵌入式脚本





5.00/5 (5投票s)
本文介绍如何在运行时编译和运行 F# 代码。
引言
在本文中,我将介绍如何使用 F# 脚本来扩展应用程序的功能。有时,需要用一些自定义行为来扩展现有应用程序的功能。如果您不想或无法每次都需要时都经过应用程序开发和构建周期,那么脚本编写可能是一个不错的解决方案。它能以最小的努力根据特定需求定制您的应用程序。
由于 F# 是 .NET 中完全受支持的语言,因此将其脚本功能集成到 .NET 应用程序中非常简单自然。我将向您展示如何将 F# 脚本添加到 Windows Forms 应用程序中。
附带的示例应用程序有两个主要部分:
- 使用 C# 编写的宿主程序。这个简单的 Windows Forms 应用程序构建在
FSCompileTestForm
项目中。 - 使用 F# 编写的嵌入式扩展,设计为一个类库。它构建在
FSExecutor
项目中。
整个应用程序是针对 .NET Framework 4.0 构建的。使用的 F# 编译器来自 F# PowerPack 包。
下面的屏幕截图展示了运行带有复数算术的简单脚本的示例。

准备工作
嵌入式脚本需要与宿主应用程序交换数据。这可以通过使用共享的通用组件或数据流来实现。有标准的输入/输出/错误流。利用简单的 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
方法将提供的文本添加到控件并设置所需的颜色。
以下是不同流的彩色文本的屏幕截图。

脚本编译和执行
现在,所有准备工作都已完成,是时候将脚本文本编译成可执行程序集了。为此,使用了 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
函数返回一个元组,其中包含已编译的程序集以及编译输出和错误消息。如果存在错误消息,它们将被打印到标准错误流,脚本执行将结束。

如果没有错误消息,则所有输出消息都将打印到标准输出流,并执行生成的程序集。
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#,但考虑到编译时间,整体性能可能更好。