ScriptEngine - C#, VB, JScript 和 F# 中的用户定义计算






4.96/5 (21投票s)
在任何 .NET 语言中启用运行时代码


引言
在将某些软件迁移到 .NET 时,我遇到了用户定义计算或脚本类的需求。在我正在开发的那款软件中,有些地方用于进行工程计算的公式,用户可以添加自己的公式,而不仅仅是从内置方法中选择。过去,我通过一个最初用汇编语言编写并基于 Forth 语言 [^] 的系统来完成这项任务。多年来,该系统被转换为使用代数表示法,并移植到 Pascal、C、C++、VB 和其他几种语言。 线程解释语言 [^] 模型提供了与编译代码相媲美的快速解释和执行速度,这在允许用户定义函数处理数百万个数据点时非常重要。
然而,在迁移到 .NET 时,似乎有一种更好的方法可以使用反射。本质上,它允许你在程序中直接嵌入标准 .NET 编译器,并“即时”编译用户输入。因此,我没有再次将我的计算引擎转换为一种新语言,而是实现了一个允许用户用任何可用的 .NET 语言编写函数的计算引擎。
使用 ScriptEngine 类
项目下载包含 ScriptEngine
C# 类,以及一个简单的程序,用于允许用户输入并执行计算。请注意,如果你尚未安装 F# 编译器 [^],你可能需要注释掉源代码中所有对 FSharp 的引用,这样 F# 就不能作为脚本语言使用了。
由于该引擎的目的是进行工程计算,所有变量都定义为 double
类型,尽管这一点可以更改。重要的字段和方法定义如下:
public enum Languages { VBasic, CSharp, JScript, FSharp };
- 定义了可识别的可用语言。public ScriptEngine(Languages language)
- 构造函数,接受一个语言作为参数。默认构造函数指定VBasic
,因为我的大多数客户最熟悉 Basic 语法。public string Code
- 允许读取或定义用户程序代码。public void AddVariable(string VariableName)
- 允许定义变量。public bool Compile()
- 编译代码并返回true
表示成功。public string[] Messages
- 一个string
数组,包含编译器消息。public void SetVariable(string VariableName, double Value)
- 允许初始化变量值。public double GetVariable(string VariableName)
- 检索变量值。public double Evaluate()
- 运行脚本并返回Result
变量的值。
请注意,用户代码应为 Result
变量设置一个值,以确定 Evaluate()
函数返回的值。默认情况下
Result = Double.NaN
使用 ScriptEngine
的一般过程是实例化一个 new ScriptEngine
对象,指定要使用的语言,使用 AddVariable
添加任何需要的变量,定义计算代码,然后 Compile()
代码并检查编译器错误。根据设计,如果存在任何编译器错误或警告,Compile()
将返回 false
,调用应用程序可以访问 Messages string
数组中的这些错误或警告,并将其显示给用户。要执行计算,使用 SetVariable
初始化变量值,使用 Evaluate()
执行计算,并使用 GetVariable
检索变量值。Evaluate()
函数返回一个 double
,这可能是计算所需的唯一值。
在我的应用程序中,ScriptEngine
在 C# 中的通常用法是在一个循环中,该循环设置所需的变量值,调用 Evaluate()
方法,然后处理结果,如下面的 C# 代码所示,使用变量 X
和 Y
。
ScriptEngine Engine = new ScriptEngine(ScriptEngine.Languages.VBasic);
Engine.Code = code;
Engine.AddVariable("X");
Engine.AddVariable("Y");
if (Engine.Compile())
{
foreach (ValueType v in Values)
{
// Set the variable values for the script
Engine.SetVariable("X", v.X);
Engine.SetVariable("Y", v.Y);
// Evaluate the script and return the Result
double result = Engine.Evaluate();
// Retrieve any variables that might have changed
double x = Engine.GetVariable("X");
// Do something with the result }
}
}
else
{
MessageBox.Show("Compiler message: " + Engine.Messages[0]);
}
ScriptEngine 内部
为了完成工作,ScriptEngine
类使用 .NET 的 CodeDomProvider
类在内存中动态创建程序集。由于我的应用程序要求之一是能够访问各种变量,因此 AddVariable
方法会使用提供的变量名添加一个类级别的字段,以及 GetVariableName
和 SetVariableName
方法,其中 VariableName
是变量名,用于设置和读取变量的值。
生成的其余代码定义了一个 namespace UserScript
,一个 class RunScript
和一个 Result
字段,以及 Evaluate()
方法,然后将用户定义代码嵌入到方法的正文中。由于支持各种语言,每种语言生成的代码略有不同。
实际编译和评估生成代码的 C# 代码如下:
public bool Compile()
{
switch (Language)
{
case Languages.CSharp:
source = "namespace UserScript\r\n{\r\nusing System;\r\n" +
"public class RunScript\r\n{\r\n" +
variables + "\r\npublic double Eval()\r\n{\r\ndouble Result =
Double.NaN;\r\n" +
code + "\r\nreturn Result;\r\n}\r\n}\r\n}";
compiler = new CSharpCodeProvider();
break;
case Languages.JScript:
source = "package UserScript\r\n{\r\n" +
"class RunScript\r\n{\r\n" +
variables + "\r\npublic function Eval() :
String\r\n{\r\nvar Result;\r\n" +
code + "\r\nreturn Result; \r\n}\r\n}\r\n}\r\n";
compiler = new JScriptCodeProvider();
break;
case Languages.FSharp:
source = "#light\r\nmodule UserScript\r\nopen System\r\n" +
"type RunScript() =\r\n" +
" let mutable Result = Double.NaN\r\n" +
variables + "\r\n" + variables1 +
" member this.Eval() =\r\n" +
code + "\r\n Result\r\n";
compiler = new FSharpCodeProvider();
break;
default: // VBasic
source = "Imports System\r\nNamespace
UserScript\r\nPublic Class RunScript\r\n" +
variables + "Public Function Eval()
As Double\r\nDim Result As Double\r\n" +
code + "\r\nReturn Result\r\nEnd Function\r\nEnd Class\
r\nEnd Namespace\r\n";
compiler = new VBCodeProvider();
break;
}
parameters = new CompilerParameters();
parameters.GenerateInMemory = true;
results = compiler.CompileAssemblyFromSource(parameters, source);
// Check for compile errors / warnings
if (results.Errors.HasErrors || results.Errors.HasWarnings)
{
Messages = new string[results.Errors.Count];
for (int i = 0; i < results.Errors.Count; i++)
Messages[i] = results.Errors[i].ToString();
return false;
}
else
{
Messages = null;
assembly = results.CompiledAssembly;
if (Language == Languages.FSharp)
evaluatorType = assembly.GetType("UserScript+RunScript");
else
evaluatorType = assembly.GetType("UserScript.RunScript");
evaluator = Activator.CreateInstance(evaluatorType);
return true;
}
}
public double Evaluate()
{
object o = evaluatorType.InvokeMember(
"Eval",
BindingFlags.InvokeMethod,
null,
evaluator,
new object[] { }
);
string s = o.ToString();
return double.Parse(s.ToString());
}
正如所见,一个 switch
语句控制着根据所选语言生成哪种源代码。大部分代码是相似的,但是由于语法和 .NET 实现的差异,F# 代码需要稍微不同地处理。
具体来说,在 F# 中,在使用 #light
语法时,缩进很重要,因此要特别注意行首的空格。读取文本的主窗体代码在 F# 中使用时会特别添加空格。此外,出于某种原因,F# 编译器生成的类型是 "UserScript+RunScript"
,带有一个 +
,而其他语言生成的类型是 "UserScript.RunScript"
,带有一个句点 (.
)。我不确定这是 F# 编译器的错误还是设计使然,但这确实在追踪过程中带来了不少挫折!
为了说明,在各种语言中,使用公式 Result = Sqrt(X*X + Y*Y)
返回给定 X
和 Y
的向量幅值的生成代码如下,并添加了缩进以提高可读性。
在C#中
namespace UserScript
{
using System;
public class RunScript
{
double X = 0;
public void SetX(double x) { X = x; }
public double GetX() { return X; }
double Y = 0;
public void SetY(double x) { Y = x; }
public double GetY() { return Y; }
public double Eval()
{
double Result = Double.NaN;
Result = Math.Sqrt(X*X + Y*Y); // This is the user code
return Result;
}
}
}
在 JScript 中
package UserScript
{
class RunScript
{
var X : double;
public function SetX(x) { X = x; }
public function GetX() : String { return X; }
var Y : double;
public function SetY(x) { Y = x; }
public function GetY() : String { return Y; }
public function Eval() : String
{
var Result;
Result = Math.sqrt(X*X + Y*Y); // This is the user code
return Result;
}
}
}
在 Visual Basic 中
Imports System
Namespace UserScript
Public Class RunScript
Dim X As Double
Public Sub SetX(AVal As Double)
X = AVal
End Sub
Public Function GetX As Double
Return X
End Function
Dim Y As Double
Public Sub SetY(AVal As Double)
Y = AVal
End Sub
Public Function GetY As Double
Return Y
End Function
Public Function Eval() As Double
Dim Result As Double
Result = (X*X + Y*Y)^0.5 ' This is the user code
Return Result
End Function
End Class
End Namespace
在 F# 中
#light
module UserScript
open System
type RunScript() =
let mutable Result = Double.NaN
let mutable X = 0.0
let mutable Y = 0.0
member x.GetX = X
member x.SetX v = X <- v
member x.GetY = Y
member x.SetY v = Y <- v
member this.Eval() =
Result <- Math.Sqrt(X*X + Y*Y) // This is the user code
Result
清除 AppDomain
根据 Uwe Keim 的一项出色观察,我添加了一个名为“ScriptEngine
”的静态 AppDomain
,它在创建 ScriptEngine
时初始化。所有 ScriptEngine
实例都放置在此 AppDomain
中,以便在所有计算完成后,调用 Unload()
方法会将动态程序集从内存中卸载。
在我的大多数应用程序中,这似乎都不是问题,但 Uwe 指出,在具有多个 ScriptEngine
实例的长期运行应用程序中使用 ScriptEngine
会导致标准 AppDomain
被污染,并且无法卸载程序集。在不再需要 ScriptEngine
时调用 Unload()
方法可以轻松规避此潜在问题。
此外,为了避免与多个 ScriptEngine
实例发生冲突,每个后续类都被命名为“RunScriptN
”,其中 N 是每次创建 ScriptEngine
实例时递增的整数。这使得多个 ScriptEngine
实例可以运行不同的代码而不发生冲突。
结论
对于实现用户定义的工程计算,ScriptEngine
似乎效果很好。除了允许进行常规计算外,使用标准的 .NET 语言还可以利用这些语言的所有特性来进行更复杂的迭代和其他计算。基本上,可以放在函数或方法体中的任何内容都可以用于用户定义代码。
至于性能,用户定义代码和常规编译代码之间的执行差异很难区分,除了在编译代码时有轻微的延迟,因为用户定义代码实际上是被编译的。我没有刻意去找出原因,但 F# 代码的执行速度似乎确实比其他语言慢一些。这可能是由于当前 F# 编译器的初步性质,或者是由于将 F# 语法映射到 .NET 所涉及的额外开销。随着时间的推移,F# 的未来版本是否会表现更好,还有待观察。
此外,使用这里介绍的方法,可以相对容易地生成其他在运行时代码。虽然我通常不赞成动态代码,因为引入几乎无法调试问题的可能性很高,但确实存在需要运行时代码生成的情况。我希望通过 ScriptEngine
作为例子,也许可以为那些实现动态运行时代码的人避免一些麻烦。
对我来说,一个主要优点是我的用户手册可以大大简化,因为我不必记录所有脚本语言。相反,我只需解释变量的使用方法,结果存储在 Result
变量中,然后将用户引荐到微软或互联网上提供的所有语言文档。这无疑节省了大量时间和精力!
一如既往,我不能声称这里提供的代码是最优的。如果任何人有任何建议或意见,我将很高兴收到。
历史
- 2008 年 11 月 15 日 - 初始提交
- 2008 年 11 月 17 日 - 修改了下载,包含 Properties 文件夹并更新了实现单独 AppDomain 的源代码。