C#发现之旅第十四讲 基于动态编译的VB.NET脚本引擎(上)

C#发现之旅第十四讲 基于动态编译的VB.NET脚本引擎(上)

脚本的原理

脚本,也可称为宏,是一种应用系统二次开发的技术,它能在应用系 统提供的一个容器环境中运行某种编程语言,这种编程语言代码调用应用系统提供的编程接 口,使得应用系统暂时“灵魂附体”,无需用户干预作而执行一些自动的操作, 此时应用系统称为“宿主”。

脚本也采用多种编程语言,比如JavaScript 语言,VBScript语言或者其他的,若采用VB则称为VB脚本。

下图为脚本原理图

C#发现之旅第十四讲 基于动态编译的VB.NET脚本引擎(上)

下图为常 规编程开发软件的原理图

C#发现之旅第十四讲 基于动态编译的VB.NET脚本引擎(上)

脚本相对 于常规的软件开发用的编程语言有着很大的不同。首先是脚本是不能独立运行的,必须在某 个应用系统搭建的容器环境中运行,脱离这个环境则脚本代码毫无作用,其逻辑和功能和应 用系统的功能联系非常紧密。脚本代码不会事先编译,而是解释执行或者临时编译执行的, 而且脚本代码的修改不会导致应用系统的重新编译和部署,而且脚本代码发生修改,应用系 统不需要重新启动即可应用修改后的脚本代码,而且运行脚本的应用系统可以不是DLL,而是 纯粹的EXE。

脚本语言大多是动态语言,所谓动态语言就是程序代码在编写时已经假 设操作的对象的类型,成员属性或方法的信息,而编译器不会进行这方面的检查。C#不是动 态语言,是静态语言,因为它在编译时会严格的检查代码操作的对象的类型,成员信息,稍 有不对则会报编译错误。VB.NET源自VB,是动态语言,它在编译时不会严格的检查对象的类 型及其成员信息,执行后期绑定,而是在运行时检查,若运行时发现对象类型和成员信息错 误,则会报运行时错误。脚本技术应当非常灵活和*,袁某人觉得此时使用C#这种静态语 言不是明智之举,而应当使用类似VB.NET这样的动态语言。

而常规的软件开发而生成 的软件大多是事先编译好的,和应用系统是独立的,软件是调用应用系统的功能而不是应用 系统的一部分。软件代码修改会导致软件的重新编译和部署,应用系统必须提供DLL格式的程 序集文件。

微软的很多软件产品有有VBA的功能,比如MS Office,甚至VS.NET集成开 发环境也有VBA宏的功能。脚本提供给应用系统二次开发的能力,而且这种二次开发能力简单 灵活,部署方便。

在应用方面脚本技术带来的最大好处就是简单灵活,部署方便。脚本代码以纯文本的格式 进行存储,修改方便,而且脚本修改后,应用系统无需重新启动而能立即使用新的脚本,脚 本代码中能实现比较复制的逻辑控制,能响应应用系统的事件,能一定程度上扩展应用系统 的功能,这有点类似数据库中的存储过程。

但脚本功能运行在应用系统提供的容器环 境中,其功能是受到严格限制的,一些脚本还受到系统权限的限制。因此脚本只能有限的扩 展应用系统的功能,若所需功能比较复杂,脚本可能无法实现,此时还得依赖传统编程。不 过在很多情况下,脚本还是能发挥很大的作用。【袁永福原创,转载请注明出处】

VB.NET脚本原理

VB.NET脚本就是采用VB.NET语法的脚本。VS.NET集成开发环 境提供的宏也是采用VB.NET语法。微软.NET框架提供了一个脚本引擎,那就是在程序集 microsoft.visualbasic.vsa.dll中的类型Microsfot.VisualBasic.Vsa.VsaEngine,该类型 在微软.NET框架1.1和2.0中都有,使用起来不算容易,而且在微软.NET框架2.0中VsaEngine 类型标记为“已过时”。在此笔者不使用VsaEngine类型来实现VB.NET脚本,而是 使用动态编译技术来实现脚本引擎。

使用动态编译技术实现VB.NET脚本引擎的原理是 ,程序将用户的脚本代码字符串进行一些预处理,然后调用 Microsoft.VisualBasic.VBCodeProvider类型的CompileAssemblyFromSource函数进行动态编 译,生成一个临时的程序集对象,使用反射技术获得程序集中的脚本代码生成的方法,主程 序将按照名称来调用这些脚本代码生成的方法。若用户修改了脚本代码,则这个过程重复一 次。

VB.NET脚本引擎设计

这里笔者将用倒推法来设计VB.NET脚本引擎,也就 是从预期的最终使用结果来反过来设计脚本引擎。

主程序将按照名称来调用脚本方法 ,很显然VB.NET代码编译生成的是一个.NET程序集类库,为了方便起见,笔者将所有的 VB.NET脚本方法集中到一个VB.NET脚本类型。笔者将脚本方法定义为静态方法,主要有两个 好处,首先脚本引擎不必生成对象实例,其次能避免由于没有生成对象实例而导致的空引用 错误,这样能减少脚本引擎的工作量。

在VB.NET语法中,可以使用代码块 “public shared sub SubName()”来定义一个静态过程,但笔者不能要求用户在 编写VB.NET脚本代码时使用“public shared sub SubName()”的VB.NET语法,而 只能使用“sub SubName()”这样比较简单的语法。同样用户在脚本中定义全局变 量时不能使用“private shared VarName as TypeName”的语法,而是简单的使 用“dim VarName as TypeName”的语法。这时笔者可以使用VB.NET语法的模块的 概念。在VB.NET语法中,将代码块“Module ModuleName ……. End Module”中的所有的代码编译为静态的。比如把“sub SubName”编译成 “public shared sub SubName()”,把“dim VarName as TypeName”编译为“public shared VarName as TypeName”。这样借助 VB.NET模块的概念就能解决了这个问题。

在一些脚本中笔者经常可以看见类似 “window.left”或者“document.location”的方式来使用全局对象 ,若笔者在VB.NET中直接使用“window.left”之类的代码必然报 “window”对象或者变量找不到的编译错误。

“window”全局 变量一般映射到应用程序的主窗体。比如“window.left”表示主窗体的左端位置 ,“window.width”标准主窗体的宽度等等。【袁永福原创,转载请注明出处】

“document”或者“window”等全局对象是映射到文档或者主 窗体等实例对象的,因此它们的成员不能定义成静态,为了能实现在脚本代码中直接使用类 似“window.left”的方法来直接使用全局对象,笔者又得使用VB.NET的一个语法 特性。在Microsoft.VisualBasic.dll中有一个公开的特性类型 “Microsoft.VisualBasic.CompilerServices.StandardModuleAttribute”,该 特性是隐藏的,可能不是微软推荐使用,但在微软.NET框架1.1和2.0都有这个特性类型,功 能也是一样的。对于一般的编程该特性是用不着的,它可附加在某个类型上,VB.NET编译器 会认为附加了该特性的类型的静态属性值就是全局对象。比如笔者定义了一个GlobalObject 类型,附加了StandardModuleAttribute特性,它有一个名为Document的静态属性,在对于脚 本中的“document.Location”代码块,VB.NET编译器会针对 “document”标识符检索所有附加了StandardModuleAttribute的类型的静态属性 ,最后命中GlobalObject类型,于是会自动扩展为 “GlobalObject.Document.Location”的代码。这个过程是在编译时进行的,在 实际运行中不再需要进行这样的查找,这样的语法特点是C#所没有的。上述的这些特点使得 VB.NET语法更适合作为脚本的语法。

类似全局对象,在VB.NET语法中具有全局函数的 功能,比如对于Asc函数,它实际上是类型Microsoft.VisualBasic.Strings的一个静态成员 函数,但在VB.NET中可以在任何时候任何地方直接使用,VB.NET编译器会将代码中的Asc函数 自动扩展为“Microsoft.VisualBasic.Strings.Asc”。这个过程是在编译时进行 的,而运行时不再需要这样的扩展。

.NET框架自带VB.NET编译器,它就是在.NET框架 安装目录下的vbc.exe,在笔者的系统中VB.NET编译器的路径是 “C:"WINDOWS"Microsoft.NET"Framework"v2.0.50727"vbc.exe”,参考MSDN中关 于VB.NET编译的命令行的说明,它支持一个名为“imports”的命令行参数指令。 比如可以这样调用VB.NET编译器“vbc.exe /imports:Microsoft.VisualBasic,system,system.drawing 其他参数”,该参数的功 能是从指定的程序集导入名称空间。在VB.NET编译器命令行中使用imports指令和在VB.NET代 码中使用Imports指令是不一样的。在源代码中使用Imports指令是用于减少代码编写量,而 在命令行中使用imports指令是启动指定名称空间下的全局对象和全局函数,若一个类型附加 了StandardModuleAttribute特性,而且定义了一些静态函数和属性,但并没有在编译器命令 行中导入带类型所在的名称空间,则VB.NET编译器不会感知到该类型中定义的全局对象和全 局函数,因此在编写VB.NET代码时必须使用“类型名称.静态属性或函数的名称” 的方式来调用全局对象和全局函数。比如若没有在VB.NET编译器的命令行参数中使用 “/imports:Microsoft.VisualBasic”参数,则Asc函数不再是全局函数,若在代 码中直接使用Asc函数则必然报编译错误,而必须使用 “Microsoft.VisualBasic.Strings.Asc”的方式来使用,即使源代码中使用了 “Imports Microsoft.VisualBasic”,也只能用“Strings.Asc”的 方式来使用函数。

如上所述,借助于StandardModuleAttribute特性和编译器命令行 参数imports,笔者就可以实现VB.NET的全局对象和全局函数了。

根据上述说明,笔 者设计如下的参与动态编译的VB.NET脚本代码的结构

Option Strict Off
Imports System
Imports Microsoft.VisualBasic
Namespace  NameSpaceXVBAScriptEngien
    Module mdlXVBAScriptEngine

         sub 脚本方法1()
            'VB.NET代码
         end sub

        sub 脚本方法2()
             'VB.NET代码
        end sub

    End  Module
End Namespace

其中斜体部分就是用户提供的原始脚本代码, 而开头和结尾部分是脚本引擎自动添加的,这样能减少脚本引擎的使用难度。

在脚本 引擎自动添加的代码中使用了Imports语句引入的名称空间,默认添加了System和 Microsoft.VisualBasic两个名称空间,为了方便使用,可以让用户添加其他的名称空间,比 如脚本代码中大量使用了System.Drawing名称空间,则可以使用Imports语句导入 System.Drawing名称空间来减少脚本代码量。

软件开发

笔者新建一个 XVBAEngine类型,该类型实现了脚本引擎的功能。脚本引擎包含了参数控制属性,代码生成 器,动态编译,分析和调用临时程序集等几个子功能。

参数控制属性

笔者为 脚本引擎类型定义了几个属性用于保存脚本引擎运行所必备的基础数据。这些属性中最重要 的属性就是用户设置的原始脚本代码文本。定义该属性的代码如下

///  <summary>
/// 脚本代码改变标记
/// </summary>
private bool bolScriptModified = true;

/// <summary>
/// 原始的VBA脚本文本
/// </summary>
private string  strScriptText = null;
/// <summary>
/// 原始的VBA脚本文本
/// </summary>
public string ScriptText
{
     get
    {
        return strScriptText;
    }
    set
    {
        if (strScriptText != value)
        {
            bolScriptModified =  true;
            strScriptText = value;
         }
    }
}

在这里ScriptText属性表示用户设置的原始的VBA 脚本代码,实际参与动态编译的脚本代码和原始设置的原始的VBA脚本代码是不一致的。当用 户修改了脚本代码文本,则会设置bolScriptModified变量的值,脚本引擎运行脚本方法时会 检查这个变量的值来判断是否需要重新动态编译操作。

此外袁某人还定义了其他的一 些控制脚本引擎的属性,其定义的代码如下

private bool bolEnabled =  true;
/// <summary>
/// 对象是否可用
///  </summary>
public bool Enabled
{
    get
     {
        return bolEnabled;
    }
    set
    {
        bolEnabled = value;
    }
}

private bool bolOutputDebug = true;
/// <summary>
/// 脚本在运行过程中可否输出调试信息
/// </summary>
public  bool OutputDebug
{
    get
    {
         return bolOutputDebug;
    }
    set
    {
         bolOutputDebug = value;
    }
}

编译脚本

笔者为脚本引擎编写了Compile函数用于编辑脚本。编译脚本的过程大体分为生成脚 本代码文本、编译脚本编译、分析脚本程序集三个步骤。

生成脚本代码文本

VB.NET脚本引擎使用的动态编译技术,而动态编译技术的第一个部分就是代码生成器 ,脚本大部分代码都是由主程序提供的,因此其代码生成器也就是将原始的脚本代码进行一 些封装而已。【袁永福原创,转载请注明出处】

根据上述对运行时脚本的设计,用户 可以导入其他的名称空间,于是脚本引擎定义了SourceImports属性来自定义导入的名称空间 ,定义该属性的代码如下

/// <summary>
/// 源代码中使用的 名称空间导入
/// </summary>
private StringCollection  mySourceImports = new StringCollection();
/// <summary>
///  源代码中使用的名称空间导入
/// </summary>
public  StringCollection SourceImports
{
    get
    {
         return mySourceImports;
    }
}

在脚本引 擎的初始化过程中,程序会默认添加上System和Microsoft.VisualBasic两个名称空间。随后 程序使用以下代码来生成实际参与编辑的脚本代码文本

// 生成编译用的完整 的VB源代码
string ModuleName = "mdlXVBAScriptEngine";
string nsName  = "NameSpaceXVBAScriptEngien";
System.Text.StringBuilder mySource =  new System.Text.StringBuilder();
mySource.Append("Option Strict Off");
foreach (string import in this.mySourceImports)
{
     mySource.Append(""r"nImports " + import);
}
mySource.Append (""r"nNamespace " + nsName);
mySource.Append(""r"nModule " +  ModuleName);
mySource.Append(""r"n");
mySource.Append (this.strScriptText);
mySource.Append(""r"nEnd Module");
mySource.Append(""r"nEnd Namespace");
string strRuntimeSource =  mySource.ToString();

这段代码功能也比较简单,首先输出“Option Strick Off”语句,然后使用mySourceImports输出若干个Imports语句。这里的 mySourceImports是一个字符串列表,用于存放引用的名称空间,比如“System” ,“Microsoft.VisualBasic”等等,用于组成VB.NET脚本的Imports语句。然后 输出Namespace和Module代码块来包括了用户提供的原始代码文本。这里的strSourceText就 是用户提供的原始代码文本。最后变量 strRuntimeSource中就包含了实际运行的VB.NET代码 文本。

编译脚本

程序生成完整的VB.NET脚本代码文本后就可以编译了,为了 提高效率,这里袁某定义了一个静态myAssemblies的哈希列表变量,定义该变量的代码如下

/// <summary>
/// 所有缓存的程序集
///  </summary>
private static Hashtable myAssemblies = new  Hashtable();

该列表缓存了以前编辑生成的程序集,键值就是脚本文本,键 值就是程序集。若缓存区中没有找到以前缓存的程序集那脚本引擎就可以调用VB.NET编译器 编辑脚本了。

为了丰富脚本引擎的开发接口,笔者使用以下代码定义了 ReferencedAssemblies属性。

/// <summary>
/// VB.NET编译 器参数
/// </summary>
private CompilerParameters  myCompilerParameters = new CompilerParameters();
/// <summary>
/// 引用的名称列表
/// </summary>
public StringCollection  ReferencedAssemblies
{
    get
    {
         return myCompilerParameters.ReferencedAssemblies;
    }
}

ReferencedAssemblies保存了编辑脚本时使用的程序集,在初始化脚本引擎 时,系统已经默认向该列表添加了mscorlib.dll、System.dll、System.Data.dll、 System.Xml.dll、System.Drawing.dll、System.Windows.Forms.dll、 Microsoft.VisualBasic.dll等.NET框架标准程序集,用户可以使用该属性添加第三方程序集 来增强脚本引擎的功能。

在前面的说明中,为了实现全局对象和全局函数,需要在VB.NET编译器的命令上中使用 imports指令导入全局对象和全局函数所在的名称空间,为此笔者定义了一个 VBCompilerImports的属性来保存这些名称空间,定义该属性的代码如下

///  <summary>
/// VB编译器使用的名称空间导入
///  </summary>
private StringCollection myVBCompilerImports = new  StringCollection();
/// <summary>
/// VB编译器使用的名称空间导 入
/// </summary>
public StringCollection VBCompilerImports
{
    get
    {
        return  myVBCompilerImports;
    }
}

在初始化脚本引擎时程序会 在VBCompilerImports列表中添加默认的名称空间Microsoft.VisualBasic。

准备和执 行编译的脚本代码和一些参数后,脚本引擎就来编译脚本代码生成临时程序集了,笔者使用 以下的代码来进行编译操作

// 检查程序集缓存区
myAssembly =  (System.Reflection.Assembly)myAssemblies[strRuntimeSource];
if (myAssembly  == null)
{
    // 设置编译参数
     this.myCompilerParameters.GenerateExecutable = false;
     this.myCompilerParameters.GenerateInMemory = true;
     this.myCompilerParameters.IncludeDebugInformation = true;
    if  (this.myVBCompilerImports.Count > 0)
    {
        //  添加 imports 指令
        System.Text.StringBuilder opt = new  System.Text.StringBuilder();
        foreach (string import in  this.myVBCompilerImports)
        {
             if (opt.Length > 0)
            {
                 opt.Append(",");
            }
             opt.Append(import.Trim());
        }
         opt.Insert(0, " /imports:");
        for (int iCount = 0;  iCount < this.myVBCompilerImports.Count; iCount++)
         {
            this.myCompilerParameters.CompilerOptions =  opt.ToString();
        }
    }//if

    if  (this.bolOutputDebug)
    {
        // 输出调试信息
        System.Diagnostics.Debug.WriteLine(" Compile VBA.NET script  "r"n" + strRuntimeSource);
        foreach (string dll in  this.myCompilerParameters.ReferencedAssemblies)
        {
             System.Diagnostics.Debug.WriteLine("Reference:" + dll);
        }
    }

    // 对VB.NET代码进行编译
    Microsoft.VisualBasic.VBCodeProvider provider = new  Microsoft.VisualBasic.VBCodeProvider();
#if DOTNET11
    // 这段 代码用于微软.NET1.1
    ICodeCompiler compiler =  provider.CreateCompiler();
    CompilerResults result =  compiler.CompileAssemblyFromSource(
         this.myCompilerParameters,
        strRuntimeSource );
#else
    // 这段代码用于微软.NET2.0或更高版本
     CompilerResults result = provider.CompileAssemblyFromSource(
         this.myCompilerParameters,
        strRuntimeSource);
#endif
    // 获得编译器控制台输出文本
     System.Text.StringBuilder myOutput = new System.Text.StringBuilder();
     foreach (string line in result.Output)
    {
         myOutput.Append(""r"n" + line);
    }
     this.strCompilerOutput = myOutput.ToString();
    if  (this.bolOutputDebug)
    {
        // 输出编译结果
        if (this.strCompilerOutput.Length > 0)
         {
            System.Diagnostics.Debug.WriteLine("VBAScript  Compile result" + strCompilerOutput);
        }
    }

    provider.Dispose();

    if  (result.Errors.HasErrors == false)
    {
        // 若没 有发生编译错误则获得编译所得的程序集
        this.myAssembly =  result.CompiledAssembly;
    }
    if (myAssembly != null)
    {
        // 将程序集缓存到程序集缓存区中
         myAssemblies[strRuntimeSource] = myAssembly;
    }
}

在这段代码中,首先程序设置编译器的参数,并为VB编译器添加引用的程序 集信息,VB.NET编译器有个名为imports的命令行参数用于指定全局名称空间。用法为 “/imports:名称空间1,名称空间2”,在编译器命令行中使用imports参数和在代 码文本中使用imports语句是有所不同的。

然后程序创建一个VBCodeProvider对象开 始编译脚本,对于微软.NET框架1.1和2.0其操作过程是有区别的。对微软.NET1.1还得调用 provider的CreateCompilter函数创建一个IcodeCompiler对象,然后调用它的 CompileAssemblyFromSource来编译脚本,而对于微软.NET框架2.0则是直接调用provider的 CompileAssemblyFromSource来编译脚本的。

编译器编译后返回一个CompilerResults 的对象表示编译结果,若发生编译错误程序就输出编译错误信息。若编译成功则程序使用编 译结果的CompileAssembly属性获得编辑脚本代码生成的临时程序集对象了。然后把程序集对 象缓存到myAssemblies列表中。

分析临时程序集

调用编译器编译脚本代码后 成功的生成临时程序集后,脚本引擎需要分析这个程序集,获得所有的可用的脚本方法,其 分析代码为

if (this.myAssembly != null)
{
    // 检 索脚本中定义的类型
    Type ModuleType = myAssembly.GetType(nsName +  "." + ModuleName);
    if (ModuleType != null)
    {
        System.Reflection.MethodInfo[] ms = ModuleType.GetMethods (
            System.Reflection.BindingFlags.Public
             | System.Reflection.BindingFlags.NonPublic
             | System.Reflection.BindingFlags.Static);
        foreach  (System.Reflection.MethodInfo m in ms)
        {
             // 遍历类型中所有的静态方法
            // 对 每个方法创建一个脚本方法信息对象并添加到脚本方法列表中。
             ScriptMethodInfo info = new ScriptMethodInfo();
             info.MethodName = m.Name;
            info.MethodObject  = m;
            info.ModuleName = ModuleType.Name;
             info.ReturnType = m.ReturnType;
             this.myScriptMethods.Add(info);
            if  (this.bolOutputDebug)
            {
                 // 输出调试信息
                 System.Diagnostics.Debug.WriteLine("Get vbs method """ + m.Name +  """");
            }
        }//foreach
         bolResult = true;
    }//if
}//if

在这段 代码中,程序首先获得脚本模块的类型,在这里类型全名为 “NameSpaceXVBAScriptEngien. mdlXVBAScriptEngine”,然后使用反射获得该 类型中所有的公开或未公开的静态成员方法对象,对于其中的每一个方法创建一个 ScriptMethodInfo类型的脚本方法信息对象来保存这个方法的一些信息,将这些信息保存到 myScriptMethods列表*以后调用。

笔者配套定义了ScriptMethodInfo类型和 myScriptMethods列表,定义它们的代码如下

/// <summary>
///  所有脚本方法的信息列表
/// </summary>
private ArrayList  myScriptMethods = new ArrayList();
/// <summary>
/// 脚本方 法信息
/// </summary>
private class ScriptMethodInfo
{
    /// <summary>
    /// 模块名称
    ///  </summary>
    public string ModuleName = null;
     /// <summary>
    /// 方法名称
    ///  </summary>
    public string MethodName = null;
     /// <summary>
    /// 方法对象
    ///  </summary>
    public System.Reflection.MethodInfo MethodObject  = null;
    /// <summary>
    /// 方法返回值
     /// </summary>
    public System.Type ReturnType =  null;
    /// <summary>
    /// 指向该方法的委托
    /// </summary>
    public System.Delegate  MethodDelegate = null;
}

使用脚本方法信息列表,脚本引擎调用脚 本方法时就不需要使用反射查找脚本方法了,只需要在脚本方法信息列表中快速的查找和调 用。

调用脚本

脚本引擎前期完成的大量的工作就是为了最后能调用脚本,为 此笔者定义了、Execute函数用于调用指定名称的脚本方法。定义该函数的代码如下

/// <summary>
/// 执行脚本方法
///  </summary>
/// <param name="MethodName">方法名称 </param>
/// <param name="Parameters">参数</param>
/// <param name="ThrowException">若发生错误是否触发异常 </param>
/// <returns>执行结果</returns>
public  object Execute(string MethodName, object[] Parameters, bool  ThrowException)
{
    // 检查脚本引擎状态
    if  (CheckReady() == false)
    {
        return null;
    }
    if (ThrowException)
    {
         // 若发生错误则抛出异常,则检查参数
        if (MethodName ==  null)
        {
            throw new  ArgumentNullException("MethodName");
        }
         MethodName = MethodName.Trim();
        if (MethodName.Length ==  0)
        {
            throw new  ArgumentException("MethodName");
        }
        if  (this.myScriptMethods.Count > 0)
        {
             foreach (ScriptMethodInfo info in this.myScriptMethods)
             {
                // 遍历所有的脚本方法信 息,不区分大小写的找到指定名称的脚本方法
                if  (string.Compare(info.MethodName, MethodName, true) == 0)
                 {
                    object result  = null;
                    if (info.MethodDelegate  != null)
                    {
                         // 若有委托则执行委托
                         result = info.MethodDelegate.DynamicInvoke (Parameters);
                    }
                     else
                    {
                        // 若没有委托则直接动态执行方 法
                        result =  info.MethodObject.Invoke(null, Parameters);
                     }
                    // 返回脚本方法返回值
                    return result;
                 }//if
            }//foreach
         }//if
    }
    else
    {
        //  若发生错误则不抛出异常,安静的退出
        // 检查参数
         if (MethodName == null)
        {
             return null;
        }
        MethodName =  MethodName.Trim();
        if (MethodName.Length == 0)
         {
            return null;
         }
        if (this.myScriptMethods.Count > 0)
         {
            foreach (ScriptMethodInfo info in  this.myScriptMethods)
            {
                 // 遍历所有的脚本方法信息,不区分大小写的找到指定名称的脚本方法
                if (string.Compare(info.MethodName, MethodName,  true) == 0)
                {
                     try
                    {
                         // 执行脚本方法
                         object result = info.MethodObject.Invoke (null, Parameters);
                        // 返 回脚本方法返回值
                        return  result;
                    }
                     catch (Exception ext)
                     {
                        // 若发生错误则输 出调试信息
                         System.Console.WriteLine("VBA:" + MethodName + ":" + ext.Message);
                     }
                     return null;
                }//if
             }//foreach
        }//if
    }//else
     return null;
}//public object Execute

这里函数参数为要调用 的脚本方法的名称,不区分大小写,调用脚本使用的参数列表,还有控制是否抛出异常的参 数。在函数里面,程序遍历myScriptMethods列表中所有以前找到的脚本方法的信息,查找指 定名称的脚本方法,若找到则使用脚本方法的Invoke函数执行脚本方法,如此陈旭就能调用 脚本了。

为了丰富脚本引擎的编程接口,笔者还定义了HasMethod函数来判断是否存 在指定名称的脚本方法,定义了ExecuteSub函数来安全的不抛出异常的调用脚本方法。

Window全局对象

在很多脚本中存在一个名为“window”的全局对 象,该对象大多用于和用户界面互换,并映射到应用系统主窗体。在这里笔者仿造HTML的 javascript脚本的window全局对象来构造出自己的window全局对象。

参考javascript 中的window全局对象,对笔者有参考意义的类型成员主要分为映射到屏幕大小或者主窗体的 位置大小的属性,还有延时调用和定时调用的方法,还有显示消息框或输入框的方法。

笔者建立一个XVBAWindowObject类型作为Window全局对象的类型。

成员属性

笔者首先定义一个UserInteractive属性,该属性指定应用系统是否能和用户桌面交 互。定义该属性的代码如下

protected bool bolUserInteractive =  true;
/// <summary>
/// 是否允许和用户交互,也就是是否显示用户 界面
/// </summary>
/// <remarks>当应用程序为ASP.NET或者 Windows Service程序时不能有图形化用户界面,因此需要设置该属性为 false.</remarks>
public bool UserInteractive
{
    get { return  bolUserInteractive; }
    set { bolUserInteractive = value; }
}

一些应用系统,包括ASP.NET和Windows Service,它是不能和用户交互的 ,不能有图形化用户界面,不能调用MessageBox函数,不能使用.NET类库中 System.Widnows.Forms名称空间下的大部分功能,若强行调用则会出现程序错误。这个脚本 引擎设计目标是可以运行在任何程序类型中的,包括WinForm,命令行模式,ASP.NET和 Windows Service。因此在这里笔者定义了UserInteractive属性用于关闭window全局对象的 某些和用户互换相关的功能,比如显示消息框,延时调用和定时调用等等,主动关闭这些功 能对应用系统的影响是不大的。

笔者还定义了其他的一些属性,其定义的代码如下

protected string strSystemName = "应用程序";
///  <summary>
/// 系统名称
/// </summary>
public string  SystemName
{
    get
    {
        return  strSystemName;
    }
    set
    {
         strSystemName = value;
    }
}

protected XVBAEngine  myEngine = null;
/// <summary>
/// 脚本引擎对象
///  </summary>
public XVBAEngine Engine
{
    get {  return myEngine; }
}

protected  System.Windows.Forms.IWin32Window myParentWindow = null;
///  <summary>
/// 父窗体对象
/// </summary>
public  System.Windows.Forms.IWin32Window ParentWindow
{
    get {  return myParentWindow; }
    set { myParentWindow = value; }
}

/// <summary>
/// 屏幕宽度
///  </summary>
public int ScreenWidth
{
    get
     {
        if (bolUserInteractive)
        {
            return  System.Windows.Forms.Screen.PrimaryScreen.Bounds.Width;
        }
        else
        {
            return  0;
        }
    }
}

///  <summary>
/// 屏幕高度
/// </summary>
public int  ScreenHeight
{
    get
    {
        if  (bolUserInteractive)
        {
            return  System.Windows.Forms.Screen.PrimaryScreen.Bounds.Height;
        }
        else
        {
             return 0;
        }
    }
}

这里的 ParentWindow属性表示应用系统的主窗体。

延时调用和定时调用

在Window全 局对象中,笔者使用System.Windows.Forms.Timer对象实现了延时调用和定时调用,由于定 时器对象属于用户互换相关的功能,因此延时调用和定时调用受到UserInteractive属性的影 响。笔者使用以下代码来实现延时调用的功能

private string  strTimeoutMethod = null;
private System.Windows.Forms.Timer myTimer;
/// <summary>
/// 设置延时调用
/// </summary>
///  <param name="MinSecend">延时的毫秒数</param>
/// <param  name="MethodName">调用的方法名称</param>
public void SetTimeout (int MinSecend, string MethodName)
{
    // 若不支持和用户互换 则本功能无效
    if ( bolUserInteractive == false)
         return;
    if (myEngine == null)
        return;
    if (myIntervalTimer != null)
    {
        //  取消当前的演示处理
        myIntervalTimer.Stop();
    }
    strTimerIntervalMethod = null;
    if (myTimer ==  null)
    {
        // 若定时器不存在则创建新的定时器对象
        myTimer = new System.Windows.Forms.Timer();
         myTimer.Tick += new EventHandler(myTimer_Tick);
    }
    // 设置定时器
    myTimer.Interval = MinSecend;
     // 设置脚本方法名称
    strTimeoutMethod = MethodName;
     // 启动定时器
    myTimer.Start();
}
///  <summary>
/// 清除延时调用
/// </summary>
public  void ClearTimeout()
{
    if (myTimer != null)
     {
        // 停止定时器
        myTimer.Stop();
    }
    // 清空延时调用的脚本方法名称
     strTimeoutMethod = null;
}

/// <summary>
/// 延时 调用的定时器事件处理
/// </summary>
/// <param  name="sender"></param>
/// <param  name="e"></param>
private void myTimer_Tick(object sender,  EventArgs e)
{
    myTimer.Stop();
    if (myEngine !=  null && strTimeoutMethod != null)
    {
         // 获得脚本方法
        string m = strTimeoutMethod.Trim ();
        strTimeoutMethod = null;
        if  (myEngine.HasMethod(m))
        {
            //  若脚本引擎中定义了该名称的脚本方法则安全的执行该脚本方法
             myEngine.ExecuteSub(m);
        }
    }
}

SetTimeout函数用于实现延时调用,它的参数为延时调用的毫秒数和脚本方 法名称。在该函数中程序初始化一个名为myTimer定时器,设置它的Interval属性为指定的毫 秒数,然后启动该定时器。而在myTimer的定时事件处理中程序停止myTimer定时器,然后调 用脚本引擎的ExecuteSub函数运行指定名称的无参数脚本方法。使用SetTimeout只会运行一 次脚本方法,在调用SetTimeout函数准备延时调用后可以调用ClearTimeout函数来立即取消 延时调用。

笔者使用以下代码来实现定时调用的功能

///  <summary>
/// 定时调用使用的定时器控件
/// </summary>
private System.Windows.Forms.Timer myIntervalTimer = null;
///  <summary>
/// 定时调用的脚本方法的名称
/// </summary>
private string strTimerIntervalMethod = null;

///  <summary>
/// 设置定时运行
/// </summary>
///  <param name="MinSecend">时间间隔毫秒数</param>
/// <param  name="MethodName">方法名称</param>
public void SetInterval(int  MinSecend, string MethodName)
{
    if (bolUserInteractive ==  false)
    {
        // 若不能和用户互换则退出处理
         return;
    }
    // 检查参数
    if  (MethodName == null || MethodName.Trim().Length == 0)
    {
        return;
    }
    if (this.myEngine ==  null)
    {
        return;
    }

     if (myTimer != null)
    {
        //取消当前的延时 调用功能
        myTimer.Stop();
    }
     strTimeoutMethod = null;

    if (myEngine.HasMethod (MethodName.Trim()) == false)
        return;
     strTimerIntervalMethod = MethodName.Trim();

    if  (myIntervalTimer == null)
    {
        // 初始化定时调 用的定时器控件
        myIntervalTimer = new  System.Windows.Forms.Timer();
        myIntervalTimer.Tick += new  EventHandler(myIntervalTimer_Tick);
    }

     myIntervalTimer.Interval = MinSecend;
}
/// <summary>
///  清除定时运行
/// </summary>
public void ClearInterval()
{
    if (myIntervalTimer != null)
    {
         // 停止定时调用
        myIntervalTimer.Stop();
    }
    strTimerIntervalMethod = null;
}
///  <summary>
/// 定时调用的定时器事件处理
/// </summary>
/// <param name="sender"></param>
/// <param  name="e"></param>
private void myIntervalTimer_Tick(object  sender, EventArgs e)
{
    if (myIntervalTimer != null)
    {
        // 设置定时调用的脚本方法名称
         strTimerIntervalMethod = strTimerIntervalMethod.Trim();
    }
    if (strTimerIntervalMethod == null
        ||  strTimerIntervalMethod.Length == 0
        || myEngine ==  null
        || myEngine.HasMethod(strTimerIntervalMethod) ==  false)
    {
        if (myIntervalTimer != null)
        {
            // 若没找到指定名称的脚本方法则停 止定时调用
            myIntervalTimer.Stop();
         }
        return;
    }
    // 安全的执行指定 名称的脚本方法
    myEngine.ExecuteSub(strTimerIntervalMethod);
}

SetInterval函数用于实现定时调用,它的参数为两次调用之间的时间间隔, 以及脚本方法名称。在该函数中程序初始化一个名为myIntervalTimer的定时器,设置它的 Interval属性为指定的时间间隔,然后启动该定时器。在myIntervalTimer的定时事件处理中 程序调用脚本引擎的ExecuteSub函数运行指定名称的无参数脚本方法。SetInterval会无休止 的定时调用脚本方法,直到调用ClearInterval函数终止定时调用。

延时调用和定时 调用是相互排斥的过程,启动延时调用会停掉定时调用,而启用定时调用会停掉延时调用。

映射应用程序主窗体

Window全局对象定义了一些属性用于映射应用程序主窗 体,笔者定义一个Title属性应用映射主窗体的文本,其代码如下

///  <summary>
/// 窗体标题
/// </summary>
public string  Title
{
    get
    {
         System.Windows.Forms.Form frm = myParentWindow as  System.Windows.Forms.Form;
        if (frm == null)
         {
            return "";
        }
         else
        {
            return  frm.Text;
        }
    }
    set
     {
        System.Windows.Forms.Form frm = myParentWindow as  System.Windows.Forms.Form;
        if (frm != null)
         {
            frm.Text = value;
         }
    }
}

类似的,笔者定义了Left,Top、Width和Height 属性用于映射主窗体的左边位置、顶边位置,宽度和高度。

借助于这些Title、Left 、Top、Width和Height属性,用户就可以在脚本中获得和设置主窗体的一些属性了。

这些属性全都是和用户互换相关的功能,因此都受UserInteractive属性控制。若ASP.NET程 序和Windows Service程序使用的脚本调用这些属性将不会产生任何效果。对于WinForm程序 ,运行脚本前应当将主窗体设置到window全局对象的ParentWindow属性上。

显示消息框

Window全局对象还定义了一些函数用于显示一些消息对话框实现用户互换。主要代 码为

/// <summary>
/// 将对象转化为用于显示的文本
/// </summary>
/// <param name="objData">要转换的对象 </param>
/// <returns>显示的文本</returns>
private  string GetDisplayText(object objData)
{
    if (objData ==  null)
        return "[null]";
    else
         return Convert.ToString(objData);
}

/// <summary>
/// 显示消息框
/// </summary>
/// <param  name="objText">提示信息的文本</param>
public void Alert(object  objText)
{
    if (bolUserInteractive == false)
         return;
    System.Windows.Forms.MessageBox.Show(
         myParentWindow,
        GetDisplayText(objText),
         SystemName,
         System.Windows.Forms.MessageBoxButtons.OK,
         System.Windows.Forms.MessageBoxIcon.Information);
}
///  <summary>
/// 显示错误消息框
/// </summary>
///  <param name="objText">提示信息的文本</param>
public void  AlertError(object objText)
{
    if (bolUserInteractive ==  false)
        return;
     System.Windows.Forms.MessageBox.Show(
        myParentWindow,
        GetDisplayText(objText),
        SystemName,
         System.Windows.Forms.MessageBoxButtons.OK,
         System.Windows.Forms.MessageBoxIcon.Exclamation);
}

///  <summary>
/// 显示一个提示信息框,并返回用户的选择
///  </summary>
/// <param name="objText">提示的文本 </param>
/// <returns>用户是否确认的信息</returns>
public bool ConFirm(object objText)
{
    if  (bolUserInteractive == false)
        return false;
     return (System.Windows.Forms.MessageBox.Show(
         myParentWindow,
        GetDisplayText(objText),
         SystemName,
         System.Windows.Forms.MessageBoxButtons.YesNo,
        System.Windows.Forms.MessageBoxIcon.Question)
        ==  System.Windows.Forms.DialogResult.Yes);
}

/// <summary>
/// 显示一个信息输入框共用户输入
/// </summary>
///  <param name="objCaption">输入信息的提示</param>
/// <param  name="objDefault">默认值</param>
/// <returns>用户输入的信 息</returns>
public string Prompt(object objCaption, object  objDefault)
{
    if (bolUserInteractive == false)
         return null;
    return dlgInputBox.InputBox(
         myParentWindow,
        GetDisplayText(objCaption),
         SystemName,
        GetDisplayText(objDefault));
}

/// <summary>
/// 显示一个文本选择对话框
///  </summary>
/// <param name="objCaption">对话框标题 </param>
/// <param name="objFilter">文件过滤器 </param>
/// <returns>用户选择的文件名,若用户取消选择则返回空引 用</returns>
public string BrowseFile(object objCaption, object  objFilter)
{
    using (System.Windows.Forms.OpenFileDialog  dlg
               = new  System.Windows.Forms.OpenFileDialog())
    {
         dlg.CheckFileExists = true;
        if (objCaption != null)
        {
            dlg.Title =  this.GetDisplayText(objCaption);
        }
        if  (objFilter != null)
            dlg.Filter = GetDisplayText (objFilter);
        if (dlg.ShowDialog(myParentWindow) ==  System.Windows.Forms.DialogResult.OK)
            return  dlg.FileName;
    }
    return null;
}
///  <summary>
/// 显示一个文件夹选择对话框
/// </summary>
/// <param name="objCaption">对话框标题</param>
///  <returns>用户选择了一个文件夹则返回该路径,否则返回空引用</returns>
public string BrowseFolder(object objCaption)
{
    using  (System.Windows.Forms.FolderBrowserDialog dlg
                = new System.Windows.Forms.FolderBrowserDialog())
    {
         if (objCaption != null)
        {
             dlg.Description = this.GetDisplayText(objCaption);
         }
        dlg.RootFolder =  System.Environment.SpecialFolder.MyComputer;
        if  (dlg.ShowDialog(myParentWindow) == System.Windows.Forms.DialogResult.OK)
            return dlg.SelectedPath;
        else
            return null;
    }
}

调用这些 方法,脚本能显示简单的消息框,显示文件选择对话框或文件夹选择对话框以实现和用户的 互换。当前这些函数都受到UserInteractive属性的控制。

这里定义了一个Alert方法 用于显示一个简单的消息框,在VB中可以调用MsgBox方法来实现相同的功能,但MsgBox方法 是VB运行库的方法,不受UserInteractive属性的控制,因此不建议使用,而使用Alert方法 。

测试脚本引擎

脚本引擎设计和开发完毕后,袁某就可以编写应用程序来测 试使用脚本引擎了。在这里笔者仿造Windows记事本开发了一个简单的文本编辑器,其用户界 面如下。

C#发现之旅第十四讲 基于动态编译的VB.NET脚本引擎(上)

在一个标准的C# WinForm项目中笔者新建一个名为frmMain的主窗体,上面放置工具条, 下面放置一个名为txtEditor的多行文本框。工具条中放上新增,打开,保存,另存为等按钮 并添加事件处理以实现简单文本编辑器的功能。

本文配套源码