利用.NET让你的程序支持脚本编程(英文)

Using .NET to make your Application Scriptable

 

AuthorTim Dawson

From www.developerfusion.co.uk

Introduction

You can go a long way to make a large application customisable. You can include a comprehensive options and preferences system or even use configuration files to allow access to advanced settings, but there's nothing like being able to write code within an application to fully control it or simply hook in to it.

I recently wrote an IRC client in .NET. People have grown accustomed to having scripting support in such things so as part of the exercise I decided to include exactly that.

How Scripting is Implemented

Scripts are implemented in a similar way to plugins. Essentially, they ARE plugins, but they're compiled on the fly from inside the application rather than precompiled. This makes it much easier for the user since they don't have to worry about unloading existing plugins, possibly closing your application then recompiling their dll.

Plugins work by conforming to an interface defined in a shared assembly that the host application knows about. Often when the plugin is initialised a reference is also passed to it of another interface that the host application has implemented, to allow the plugin to control the behaviour and other aspects of that application.

Luckily for us, in .NET the compilers for the main languages are built right in to the framework. These are worth an article on their own really, but for the purposes of this article we will but scratch the surface. The key to us using them for this purpose is that they can accept language source as a string and spit out an assembly in memory. This assembly object acts just the same as an assembly object loaded from a compiled dll.

By the end of this article I hope to have taken you through the process of doing this, and have developed a helper class in both VB and C# that illustrates the processes involved.

The Object Model, Host Application & Script Editor

This part is just the same as writing for plugin-based applications. The first step is to create a shared dll which is referenced by the host application and contains the interfaces needed to be known by both that and the script. My imagination being what it is, for this article we'll make a simple host application that has four buttons on it and calls methods in a script when each button is clicked. It will make available to the script a UI control and a method to show a message box.

Here are the interfaces:

[VB]
Public Interface IScript
    Sub Initialize(ByVal Host As IHost)
    Sub Method1()
    Sub Method2()
    Sub Method3()
    Sub Method4()
End Interface
Public Interface IHost
    ReadOnly Property TextBox() As System.Windows.Forms.TextBox
    Sub ShowMessage(ByVal Message As String)
End Interface

[C#]
using System;
namespace Interfaces
{
    public interface IScript
    {
        void Initialize(IHost Host);
        void Method1();
        void Method2();
        void Method3();
        void Method4();
    }
    public interface IHost
    {
        System.Windows.Forms.TextBox TextBox { get; }
        void ShowMessage(string Message);
    }
}

The Host Application

Before we get in to how we're going to edit and compile the script, let's design the simple host application. All we want is a form with a textbox and four buttons on it. We will also want two variables scoped to the whole form, one to store the current script source and one of type IScript that we'll use to call the compiled script's methods. The form itself will implement IHost for simplicity. We also want another button that we'll use to show the script editing dialog.

[VB]
Public Class Form1
    Inherits System.Windows.Forms.Form
    Implements Interfaces.IHost
    'Designer stuff removed for clarity
    Private ScriptSource As String = ""
    Private Script As Interfaces.IScript = Nothing
    Public ReadOnly Property TextBox() As System.Windows.Forms.TextBox Implements _
    Interfaces.IHost.TextBox
        Get
            Return txtOutput
        End Get
    End Property
    Public Sub ShowMessage(ByVal Message As String) Implements _
    Interfaces.IHost.ShowMessage
        MessageBox.Show(Message)
    End Sub
    Private Sub btnFunction1_Click(ByVal sender As System.Object, ByVal e As _
    System.EventArgs) Handles btnFunction1.Click
        If Not Script Is Nothing Then Script.Method1()
    End Sub
    Private Sub btnFunction2_Click(ByVal sender As System.Object, ByVal e As _
    System.EventArgs) Handles btnFunction2.Click
        If Not Script Is Nothing Then Script.Method2()
    End Sub
    Private Sub btnFunction3_Click(ByVal sender As System.Object, ByVal e As _
    System.EventArgs) Handles btnFunction3.Click
        If Not Script Is Nothing Then Script.Method3()
    End Sub
    Private Sub btnFunction4_Click(ByVal sender As System.Object, ByVal e As _
    System.EventArgs) Handles btnFunction4.Click
        If Not Script Is Nothing Then Script.Method4()
    End Sub
    Private Sub btnEditScript_Click(ByVal sender As System.Object, ByVal e As _
    System.EventArgs) Handles btnEditScript.Click
        'TODO
    End Sub
End Class

[C#]
public class Form1 : System.Windows.Forms.Form, Interfaces.IHost
{
    // Designer stuff removed for clarity
   
    string ScriptSource = "";
    Interfaces.IScript Script = null;
    public System.Windows.Forms.TextBox TextBox
    {
        get
        {
            return txtOutput;
        }
    }
    public void ShowMessage(string Message)
    {
        MessageBox.Show(Message);
    }
    private void btnFunction1_Click(object sender, System.EventArgs e)
    {
        if (Script != null) Script.Method1();
    }
    private void btnFunction2_Click(object sender, System.EventArgs e)
    {
        if (Script != null) Script.Method2();
    }
    private void btnFunction3_Click(object sender, System.EventArgs e)
    {
        if (Script != null) Script.Method3();
    }
    private void btnFunction4_Click(object sender, System.EventArgs e)
    {
        if (Script != null) Script.Method4();
    }
    private void btnEditScript_Click(object sender, System.EventArgs e)
    {
        // TODO
    }
}

Providing a Script Editor

This is probably the most tedious part to get working really well. The three most important things to implement here in a commercial application are a good code editor (I suggest using a commercial syntax highlighting control), some form of intellisense or helpers for code creation and background compilation of the script to highlight errors as they are typed, like in Visual Studio. For this article we'll just use a textbox for code editing with no background compilation.

The script editor form needs to be able to accept script source, which will go in to its main editing textbox. It needs to have Ok and Cancel buttons, and a listview to display compile errors should there be any. But most importantly, it needs to be able to compile the script and produce an instance of the class that implements IScript to be handed back to the main form.

 

Dynamic Compilation

Finally on to the meat of the article. As I said already, compiler services are built in to the .NET framework. These live in the System.CodeDom namespace. I say compiler services and not compilers because CodeDom encompasses a lot more than that. However, the bits we're interested in live in the System.CodeDom.Compiler namespace.

To start off with we need an instance of a class that inherits from CodeDomProvider. This class provides methods to create instances of other helper classes specific to that language. Derivatives of CodeDomProvider are shipped with the framework for all four .NET languages, however the only two we're interested in are VB and C#, the most popular. They are Microsoft.VisualBasic.VBCodeProvider and Microsoft.CSharp.CSharpCodeProvider. The design of this system is so good that after choosing which of these to use, the steps are exactly the same.

The first thing we use is the CodeDomProvider's CreateCompiler method to get an instance of a class implementing ICodeCompiler. Once we have this that's the last we need from our CodeDomProvider. Our helper class will have a CompileScript function, which will accept script source, the path to an assembly to reference and a language to use. We'll overload it so the user can use their own codeprovider if they want to support scripting in a language other than VB or C#.

The next step once we have our ICodeCompiler is to configure the compiler. This is done using the CompilerParameters class. We create an instance of it using the parameterless constructor and configure the following things:

  • We don't want an executable, so set GenerateExecutable to false.
  • We don't want a file on disk, so set GenerateInMemory to true.
  • We don't want debugging symbols in it, so set IncludeDebugInformation to false.
  • We add a reference to the assembly passed as a parameter.
  • We add a reference to System.dll and System.Windows.Forms.dll to make the TextBox usable.

As a side note, to provide information for compilers specific to the language you are using (such as specifying Option Strict for VB) you use the CompilerOptions property to add extra command-line switches.

Once we have our CompilerParameters set up we use our compiler's CompileAssemblyFromSource method passing only our parameters and a string containing the script source. This method returns an instance of the CompilerResults class. This includes everything we need to know about whether the compile succeeded - if it did, the location of the assembly produced and if it didn't, what errors occured.

That is all this helper function will do. It will return the CompilerResults instance to the application for further processing.

[VB]
Public Shared Function CompileScript(ByVal Source As String, ByVal Reference As String, _
ByVal Provider As CodeDomProvider) As CompilerResults
    Dim compiler As ICodeCompiler = Provider.CreateCompiler()
    Dim params As New CompilerParameters()
    Dim results As CompilerResults
    'Configure parameters
    With params
        .GenerateExecutable = False
        .GenerateInMemory = True
        .IncludeDebugInformation = False
        If Not Reference Is Nothing AndAlso Reference.Length <> 0 Then _
            .ReferencedAssemblies.Add(Reference)
        .ReferencedAssemblies.Add("System.Windows.Forms.dll")
        .ReferencedAssemblies.Add("System.dll")
    End With
    'Compile
    results = compiler.CompileAssemblyFromSource(params, Source)
    Return results
End Function

[C#]
public static CompilerResults CompileScript(string Source, string Reference,
CodeDomProvider Provider)
{
    ICodeCompiler compiler = Provider.CreateCompiler();
    CompilerParameters parms = new CompilerParameters();
    CompilerResults results;
    // Configure parameters
    parms.GenerateExecutable = false;
    parms.GenerateInMemory = true;
    parms.IncludeDebugInformation = false;
    if (Reference != null && Reference.Length != 0)
        parms.ReferencedAssemblies.Add(Reference);
    parms.ReferencedAssemblies.Add("System.Windows.Forms.dll");
    parms.ReferencedAssemblies.Add("System.dll");
    // Compile
    results = compiler.CompileAssemblyFromSource(parms, Source);
    return results;
}

We also need one more helper function, which we will pretty much completely take straight from the plugin services we developed in my previous tutorial. This is the function that examines a loaded assembly for a type implementing a given interface and returns an instance of that class.

[VB]
Public Shared Function FindInterface(ByVal DLL As Reflection.Assembly, _
ByVal InterfaceName As String) As Object
    Dim t As Type
    'Loop through types looking for one that implements the given interface
    For Each t In DLL.GetTypes()
        If Not (t.GetInterface(InterfaceName, True) Is Nothing) Then
            Return DLL.CreateInstance(t.FullName)
        End If
    Next
    Return Nothing
End Function

[C#]
public static object FindInterface(System.Reflection.Assembly DLL, string InterfaceName)
{
    // Loop through types looking for one that implements the given interface
    foreach(Type t in DLL.GetTypes())
    {
        if (t.GetInterface(InterfaceName, true) != null)
            return DLL.CreateInstance(t.FullName);
    }
    return null;
}

 

Analysing the Compile Results

Back to our script editing dialog. In the handler procedure for the Ok button, we need to use the procedure we've just written to compile the source code. We'll start by getting the path to the assembly the compiler needs to reference - our interfaces assembly. This is easy since it's in the same directory as the running host application. Then we clear our "errors" listview and run the CompileScript function.

[VB]
    Dim results As CompilerResults
    Dim reference As String
    'Find reference
    reference = System.IO.Path.GetDirectoryName(Application.ExecutablePath)
    If Not reference.EndsWith("/") Then reference &= "/"
    reference &= "interfaces.dll"
    'Compile script
    lvwErrors.Items.Clear()
    results = Scripting.CompileScript(ScriptSource, reference, _
    Scripting.Languages.VB)

[C#]
    CompilerResults results;
    string reference;
    // Find reference
    reference = System.IO.Path.GetDirectoryName(Application.ExecutablePath);
    if (!reference.EndsWith(@"/"))
        reference += @"/";
    reference += "interfaces.dll";
    // Compile script
    lvwErrors.Items.Clear();
    results = Scripting.CompileScript(ScriptSource, reference,
    Scripting.Languages.VB);

Next we look at the Errors collection of our results. If it is empty, the compile succeeded. In this case, we use our FindInterface method to instantiate the class from the assembly and store it, then return DialogResult.Ok to the procedure that showed the script editor. If the Errors collection is not empty, we iterate through it populating the listview with all the errors that occured during the compile.

[VB]
    Dim err As CompilerError
    Dim l As ListViewItem
    'Add each error as a listview item with its line number
    For Each err In results.Errors
        l = New ListViewItem(err.ErrorText)
        l.SubItems.Add(err.Line.ToString())
        lvwErrors.Items.Add(l)
    Next
    MessageBox.Show("Compile failed with " & results.Errors.Count.ToString() & _
    " errors.", Application.ProductName, MessageBoxButtons.OK, MessageBoxIcon.Stop)

[C#]
    ListViewItem l;
    // Add each error as a listview item with its line number
    foreach (CompilerError err in results.Errors)
    {
        l = new ListViewItem(err.ErrorText);
        l.SubItems.Add(err.Line.ToString());
        lvwErrors.Items.Add(l);
    }
    MessageBox.Show("Compile failed with " + results.Errors.Count.ToString() +
    " errors.", Application.ProductName, MessageBoxButtons.OK, MessageBoxIcon.Stop);

 

Finishing Off

The only part left to do is to finish off the main form by making the Edit Script button work as it should. This means showing the dialog and, if the compile was successful, storing the compiled script and initialising it.

[VB]
Private Sub btnEditScript_Click(ByVal sender As System.Object, ByVal e As _
System.EventArgs) Handles btnEditScript.Click
    Dim f As New frmScript()
    'Show script editing dialog with current script source
    f.ScriptSource = ScriptSource
    If f.ShowDialog(Me) = DialogResult.Cancel Then Return
    'Update local script source
    ScriptSource = f.ScriptSource
    'Use the compiled plugin that was produced
    Script = f.CompiledScript
    Script.Initialize(Me)
End Sub

[C#]
private void btnEditScript_Click(object sender, System.EventArgs e)
{
    frmScript f = new frmScript();
    // Show script editing dialog with current script source
    f.ScriptSource = ScriptSource;
    if (f.ShowDialog(this) == DialogResult.Cancel)
        return;
    // Update local script source
    ScriptSource = f.ScriptSource;
    // Use the compiled plugin that was produced
    Script = f.CompiledScript;
    Script.Initialize(this);
}

That's it! When you open the sample solutions you'll notice that I wrote a blank, template script that simply provides a blank implementation of IScript and embedded it as a text file in the host application. Code in the constructor of the main form sets the initial script source to this. You'll also notice that I used VB scripting for this example, but changing one line of code and writing a new default script is all you need to do to make it use C# code to script instead.

To try it out, run the solution. Click the Edit Script button on the main form and put whatever you like in the four methods. You can use Host.ShowMessage to cause the host application to show a messagebox and Host.TextBox to use that textbox on the main form. Press Ok, and assuming the compile succeeds you can press the four buttons to run the four corresponding procedures in the script you just compiled.

In the real world the scripting interfaces would obviously be much more complicated than this, but this should give you a pretty good idea of how to compile on the fly and hook up a compiled script to your application. I have provided both a VB and C# solution to go with this article, which are functionally identical. Open the Scripting.sln file in the Scripting directory.

 

 
  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值