使用 CodeDom 进行动态代码集成
虽然市面上有很多表达式求值器,但CodeDom框架允许您在运行时将任何.NET语言与代码片段关联起来。
引言
我偶尔会实现一个程序,我希望能够交互式地更改某些参数或交互式地重写一部分代码。参数可以通过现有的GUI组件轻松解决。 .NET框架的System.CodeDom
命名空间为后者提供了一套很好的工具。存在许多关于使用CodeDom文档的教程,其中大多数都侧重于创建源代码文件。在.NET 2.0中,添加了从内部字符串编译代码的支持。本文将介绍如何从文本框等位置获取一段代码,并立即在您的系统中使用它。我还将介绍一些简单的用例,允许您从用户那里获取单个表达式或计算,并将其嵌入到委托函数或用户看不到的自己的界面中。在第二部分,我将介绍一些健壮性问题,提供更多示例,并讨论与新的LINQ技术相关的实用性。请注意,我将使用C#来解释这一点,但这个概念对于任何.NET语言来说都同样适用。
初始设置
在本教程的这一部分,我们将专注于一项简单任务:将字符串输出到控制台。让我们从这个简单的程序开始,看看如何重构它以提供更多灵活性。文章很长,主要是因为我将采取循序渐进的方式,并在代码重构时重复相关部分。在教程结束之前,我将忽略任何错误检查或异常。考虑这个非常简单的控制台应用程序
class Foo
{
public void Print()
{
System.Console.WriteLine("Hello from class Foo");
}
}
static void Main( string[] args )
{
Foo myFoo = new Foo();
myFoo.Print();
}
动态编译
我的最终目标是以文本字符串的形式获取源代码。因此,作为第一个测试,我将上面的代码转换为字符串,然后在Main
中,我将使用CodeDom技术和反射创建一个Foo
实例。这需要三个步骤:
- 创建一个支持
ICodeCompiler
接口的对象的实例。就我们而言,我们想要一个处理C#的实例,因此我们将使用Microsoft.CSharp
命名空间中的CSharpCodeProvider
。 - 将源代码编译成程序集。
- 使用反射处理程序集以获取类型信息,并创建类型实例。
为了达到这个目的,我将创建一个名为CompileSource
的实用方法,该方法将接受一个字符串并输出一个程序集。代码如下:
private static Assembly CompileSource( string sourceCode )
{
CodeDomProvider cpd = new CSharpCodeProvider();
CompilerParameters cp = new CompilerParameters();
cp.ReferencedAssemblies.Add("System.dll");
cp.ReferencedAssemblies.Add("ClassLibrary1.dll");
cp.GenerateExecutable = false;
// Invoke compilation.
CompilerResults cr = cpd.CompileAssemblyFromSource(cp, sourceCode);
return cr.CompiledAssembly;
}
相当直接;除了创建ICodeCompiler
和编译源代码之外,我们还需要创建一个CompilerParameters
实例并进行配置。请注意,您可以添加任何您希望向用户公开的引用。结果程序集是从CompileAssemblyFromSource
方法返回的CompilerResults
类的实例中提取的。好的,现在让我们来使用它。下面是新的应用程序(不包含上面的CompileSource
代码)。我正在使用字符串@
运算符来指定源代码。使用这种语法,字符串中的任何引号都需要重复。
namespace CreateDelegate
{
class Program
{
static void Main( string[] args )
{
Test1();
}
private static void Test1()
{
//
// Create an instance of type Foo and call Print
//
string FooSource = @"
class Foo
{
public void Print()
{
System.Console.WriteLine(""Hello from class Foo"");
}
}";
Assembly assembly = CompileSource(FooSource);
object myFoo = assembly.CreateInstance("Foo");
// myFoo.Print(); // - Print not a member of System.Object
// ((Foo)myFoo).Print(); // - Type Foo unknown
}
}
}
请注意,Main
已成功创建了Foo
类的实例,但存在一些问题:
Main
硬编码了类名(“Foo”)。- 当您编译此应用程序时,类型
Foo
不存在。它是在运行时创建的。因此,我们必须使用统一类型object
来处理我们的Foo
实例。无法将其强制转换为Foo
类型。 - 由于上面的第2点,我无法使用
myFoo.Print
调用Print
。这将产生编译器错误,因为类型object
没有Print
方法。
因此,新版本不打印任何内容。使用反射,可以获取Foo
类的所有方法的列表,并直接Invoke
Print
方法。直接这样做相当笨拙,并且不是一个非常有用的编程实践。相反,我将介绍两种处理这些类型的方法,它们提供了更优雅的解决方案:
- 面向接口编程
- 使用委托
这两种方法都需要主程序、源代码字符串或两者都有额外的知识。使用哪种取决于您应用程序的具体需求,稍后将在用例中进行讨论。
面向接口编程
现代软件设计的一个基本原则是面向接口编程。对于我们的简单示例,我们将添加一个IPrint
接口:
namespace FooLibrary
{
public interface IPrint
{
void Print();
}
}
我将其放在FooLibrary
命名空间中。仅有一个接口是不够的。它需要能够被任何希望实现它的类访问。最简单的方法是将您的关键接口放入一个或多个类型库(DLL)中。我已经为这个项目创建了DLLIPrint.dll。我还将其添加为我们控制台应用程序的引用。修改后的测试如下:
using FooLibrary;
private static void Test2()
{
//
// Create an instance of type FooLibrary.IPrint and call Print
//
string FooInterface = @"
class Foo : FooLibrary.IPrint {
public void Print() {
System.Console.WriteLine(""Hello from class Foo"");
}
}";
Assembly assembly = CompileSource(FooInterface);
IPrint foo = assembly.CreateInstance("Foo") as IPrint
if (foo != null)
{
foo.Print();
}
}
我们做了以下更改:
- 我们添加了
FooLibrary
的using
语句。 - 我们将
CreateInstance
的结果强制转换为IPrint
类型(使用as
运算符)。 - 如果转换成功,我们现在可以调用
Print
()。
为了使我们的基于字符串的源代码能够编译,它需要访问IPrint.dll程序集中的IPrint
类型。我们的CompileSource
方法需要添加以下行:
cp.ReferencedAssemblies.Add("IPrint.dll");
请注意,即使我们包含了FooLibrary
的using
语句,我们仍然需要完全限定字符串fooSource
中的IPrint
。这是有道理的,因为它被视为一个单独的编译单元。我们也可以在字符串中添加一个额外的using FooLibrary
。
使用反射搜索类型
这解决了上述三个问题中的两个。Main
仍然硬编码了对字符串名称“Foo”的引用。将类名硬编码可以很容易地替换为额外的用户控件,让用户指定要创建的类型的名称。这可能会出错,因为用户更改了代码,但可能忘记更改名称。为了从Main
中移除知道具体类型名称的实现智能,我们可以搜索程序集。我们想要的是获取一个程序集和一个已知的接口名称,然后搜索实现该接口的具体类型名称。这可以通过以下方式实现:
private static void Test2()
{
//
// Create an instance of type FooLibrary.IPrint and call Print
//
string FooInterface = @"
class Foo : FooLibrary.IPrint {
public void Print() {
System.Console.WriteLine(""Hello from class Foo"");
}
}";
Assembly assembly = CompileSource(FooInterface);
Type fType = assembly.GetTypes()[0];
Type iType = fType.GetInterface("FooLibrary.IPrint");
if (iType != null)
{
FooLibrary.IPrint foo = (FooLibrary.IPrint)
assembly.CreateInstance(fType.FullName);
foo.Print();
}
}
在这里,我们假设只有一个类型,或者我们感兴趣的类型是第一个。这可以很容易地更改为搜索所有类型,留给读者作为练习。因此,我们现在可以创建支持我们接口的类型的实例,其中类型定义最初是一个文本字符串。如果最终目标只是执行Print()
方法中的语句,那么可以使用委托构建一个更简单的方案,我们将在下面讨论。
动态委托
让我们考虑这样一种情况,我们希望允许用户使用某个用户界面组件指定一个任意的二维函数 f(x,y)。示例如下:
float func2D( float x, float y)
{
float value; value = Math.Cos(x) * Math.Sin(x) + 1.0f;
return value;
}
有很多关于创建自己的表达式引擎和解析此类函数的文章。使用CodeDom,您可以将任何.NET语言视为脚本语言。这个函数与我们上面的Print
方法有什么不同?嗯,首先,它不包含在类或命名空间中。您可以使用反射从全局命名空间访问此函数,但我将采取不同的路线。我将把它包装在一个类中,以便我们可以使用与上面类似的逻辑。我以非常特定的方式构建了这段代码。我希望用户输入的只是行(或一组语句):
value = Math.Cos(x) * Math.Sin(y) + 1.0f;
这将使用户不必知道确切的函数签名、类名、接口名称等。如何?通过字符串操作!我定义了两个额外的字符串,称为methodHeader
和methodFooter
。然后,我取表达式的字符串,并将其夹在头部和尾部字符串之间,然后再将其传递给CompileSource
方法。例如,原始示例可以这样编码:
string FooSourcHeader = @"
using System;
class Foo {
static public void Print()
{";
string FooSourceFooter = @"
}
}";
string myPrint = FooSourcHeader
+ @"Console.WriteLine(""Embedded Hello"");"
+ FooSourceFooter;
Assembly fooAssembly = CompileSource(myPrint);
IPrint myFoo = fooAssembly.CreateInstance(“Foo”) as IPrint;
myFoo.Print();
这样做有什么好处?嗯,现在我知道“Foo”是类名,并且它实现了IPrint
接口。我对这些字符串有控制权!用户只需要知道他们需要提供一个零个或多个语句序列。我甚至可以提供一个更详细的类定义,并告诉用户他们可以访问哪些字段。好的,这很酷,但这一部分是关于动态委托的。委托定义了方法签名的类型。它们通常用于回调和事件。任何具有相同签名的方法都可以被视为委托的类型。
delegate void PrintDelegate();
private static void Test3()
{
//
// Get a PrintDelegate from an instance of the type Foo.
//
string FooSourcHeader = @"
using System;
class Foo {
static public void Print()
{";
string FooSourceFooter = @"
}
}";
string myPrint = FooSourcHeader
+ @"Console.WriteLine(""Hello from Print delegate"");"
+ FooSourceFooter;
Assembly assembly = CompileSource(myPrint);
//
// Try to Invoke a method
//
Type fooType = assembly.GetType("Foo");
MethodInfo printMethod = fooType.GetMethod("Print");
PrintDelegate fooPrint = (PrintDelegate)
Delegate.CreateDelegate(typeof(PrintDelegate), printMethod);
fooPrint();
}
摘要
好了,这就是全部。我们可以使用文本字符串在运行时向我们的系统添加功能。我们可以根据需要允许完整的类定义,或者简单的函数体。在第二部分,我将提供一些用户控件和基于这些想法构建的示例演示。我还将讨论一些健壮性问题,以及如何将任何编译器错误反馈给用户。
历史
- 2008-05-22 - 初始文章。