Script Happens .NET
June 25, 2001
Writing applications that host a script engine to enable people to write scripts to customize and extend applications has proven to be very successful; there are thousands of developers using the Windows® Script engines in their applications. Many of you are now looking at .NET and wondering how scripting fits into the .NET strategy, since the Windows Script engines are COM based. This article coincides with the release of .NET Beta 2, which includes, for the first time, Script for the .NET Framework. So, I thought I'd discuss how Script for the .NET Framework works, and how it relates to Windows® Script. To help illustrate this, I've taken the example I used in my July 1999 column—a simple notepad-like application—and ported it to .NET to use Script for the .NET Framework.
What Is Script for the .NET Framework?
Script for the .NET Framework is the .NET equivalent of Windows Script, in that it provides a set of script engines that can be hosted within an application via a set of integration interfaces. Much in the same way as the Windows Script engines were part of the default install of Windows, the Script for the .NET Framework engines are included in the .NET Framework redistributable and are freely redistributable along with the rest of the .NET Framework. In this release of Script for the .NET Framework there are three script engines: Visual Basic® .NET, JScript® .NET and a compiled script loader engine. The loader engine is a script engine that can run compiled code but not compile—more on this in future articles.
What's New with the Script Engines?
The .NET Script engines provide a considerable upgrade in capabilities from today's engines. Improvements include:
- Full platform access. No more dealing with just IDispatch objects! This was one of the biggest complaints we got from the scripting user community, since it meant that you had to think about designing an object model specifically for script, which might not have been the ideal model for use in other languages.
- First class languages. VBScript and JScript are great script languages, but there were a number of language features that limited their use—in particular types and early binding. We've rewritten JScript to be 100% .NET, so you can take advantage of all the great functionality provided by the platform within JScript. This means you no longer have to worry about dealing with system arrays and the like. JScript .NET just does the right thing for you. Likewise, the Visual Basic .NET language used by the scripting engine is exactly the same as the Visual Basic .NET language used by Visual Basic .NET. This means you don't have to deal with the differences between Visual Basic and VBScript anymore.
- Compilation. One of the great advantages of script, from a script author's perspective, is that you don't need to go through a specific compile step. You just save your code and run. This capability is still preserved in Script for the .NET Framework, but rather than interpreting the code, the engines now compile the code to Intermediate Language (IL), which .NET uses. This can provide considerable performance improvements, especially when used with the new ability to persist the compiled form—you can use the precompiled script rather than compiling it every time you want to run the code.
- Simpler integration. If you wanted to host a Windows Script engine, you could either go directly to the interfaces or use the Windows Script control. Unfortunately, this wasn't an option for you if you were a Visual Basic developer, since the IActiveScript interfaces weren't callable from Visual Basic. Since the new interfaces are fully .NET aware, using them from within Visual Basic .NET, or any .NET language, is possible and even easy. I'll go into the details of using these interfaces later, with my ScriptPad .NET example.
- IDE availability for editing and debugging with Microsoft® Visual Studio for Applications. Script for the .NET Framework provides a rich run-time platform. Yet providing a full-featured editing and debugging environment for script is something that we get asked for a lot. Visual Studio for Applications provides a first-class integrated development environment based on Visual Studio .NET that uses the Script for the .NET Framework run time. This means that once you've integrated script into your application, and you've realized that your text-box based script editor isn't quite what your users had in mind (check out the editor in ScriptPad for an idea of the state-of-the-text-box art), you can license the VSA IDE for use in your application without having to throw out all your script engine integration. VSA comes with an extensive SDK that provides a number of classes that make integration of the IDE and the script engines even easier. I recommend downloading the beta SDK. You can find out more about VSA and how it relates to Script for the .NET Framework in my January article, Introducing Visual Studio for Applications.
- Security. Ensuring that any script code written in your application can be controlled to only access the objects that you want them to access is key, especially in the increasingly distributed nature of applications. Script for the .NET Framework provides full access to the .NET security model, allowing for fully verifiable security combined with trust. Allowing the hosting application can fully "sand box" the security context of a script's execution environment.
When designing Script for the .NET Framework, we tried to keep the design similar in concept to the IActiveScript interfaces, but we also took the opportunity to simplify them (just how many states can an engine have anyway?) and provide for the new capabilities provided by the new engines and .NET. If you're familiar with IActiveScript, then the new interfaces (IVsaEngine, IVsaSite) should be familiar. I won't go over all the interfaces here, but will try to give you the basics required to get a script engine up and running.
To use an engine, you need to create an instance of the appropriate engine, create a code item, add the script to the code item, and then add the object model that the script will use. Adding the object model is achieved via either adding a global object or an event source object. This allows you to add specific objects that will expose events to the scripter, rather than having to expose events on all the objects in the engine. When an object is added into the engine, you don't pass in the instance; instead, you specify the type and name you want to give the object. When the script is run, the engine will call you back on IVSASite to get the instance.
This can be advantageous when running, since it allows you to defer the creation of the object until the script engine needs it. Since the .NET Script engines can access any .NET class, the engines now support the ability to add references to .NET classes so the script code can use the types in the reference. A reference is implemented as an item in the engine; you simply tell it the name and the path to the assembly you're referencing.
Once you've got the engine set up and ready to go, all that is required is to compile the script and run it. These steps are distinct, so you can compile the script code without running any of the script. This is extremely useful if you want to provide the user with feedback about any errors in their script before the script runs. If there is an error, you get full information about where the error occurred. I use this in my example to provide a simple syntax checker. This feature was often requested by those of you using Windows Script, so hopefully this should make your life (and your users') a little easier. Assuming that the compile goes well, all that remains is to run the script, which hooks up any objects and creates a .NET assembly that contains the script.
Script for the .NET Framework in Action
Hopefully I've whetted your appetite with my overview of Script for the .NET Framework, but it's about time we got down to some real code to show how it all works. To illustrate using Script for the .NET Framework, I've resurrected my simple notepad-like application from the original Script Happens article, and brought it into the .NET world. When building the application, I was going to just port it to .NET and use the Script for the .NET Framework engines. It occurred to me, however, that if this was a real application, I'd have a bunch of existing Windows Script scripts written, and I would probably want to keep them for backwards compatibility. As a result, ScriptPad .NET will support both Windows Script and Script for the .NET Framework engines, and I've tried to create an implementation that abstracts the differences between the two technologies into a common abstraction that provides a class that is very close to the successful Windows Script control interface.
Step 1: Writing the Application in .NET
When deciding to write ScriptPad .NET, I spent some time trying to work out what language to write the application in. Since there's a lot of .NET code out there written in C#, I thought I'd use Visual Basic .NET to implement the majority of the program since it happens to be one of the languages we're providing script engines for. Of course there's nothing stopping you from using C# , JScript .NET, or any of the other .NET-based languages (Click here to see the list). You can take a look at the source for the Visual Basic implementation in the download that accompanies this article.
ScriptPad .NET is a simple application—just a Windows form with a RichText control and a simple menu system. In theory, it will load and save RTF files from disk, but I figure it is more important to show you how to integrate script than to load files. The script integration is based on being able to access the RichText control's methods, properties, and events. Again, to keep things simple, I've just used the base object model provided by the RichText control rather than designing an object model specifically for ScriptPad .NET.
Figure 1. ScriptPad .NET in all its glory
Step 2: Hosting an Engine
Since I wanted to create a mechanism to deal with both Windows Script engines and Script for the .NET Framework engines, I built a base class with a definition very similar to that of the Windows Script control. To keep it simple, the class only allows a single module to be added to the script, and you can add script code, objects, and references to the single module. Once all the items required have been added to the engine, it's important to be able to compile and run the engine, so there are methods for this as well. Finally, it's good to be able to get at the modules and procedures into the engine once it's running, so there are also read-only properties exposing these. Here's the definition of the IScriptHost interface that is the basis for the script hosting in the engine:
Interface IScriptHost Property ScriptSrc() As String ReadOnly Property Procedures() As MethodInfo() ReadOnly Property Modules() As String() Function AddCode(ByVal Code As String) As System.Object Sub AddObject(ByVal Name As String, ByVal Type As String,_ ByRef Instance As System.Object, ByVal AssemblyName As String,_ Optional ByVal Events As Boolean = True) Sub AddReference(ByVal Name As String, ByVal AssemblyName As String) Sub Compile() Function Invoke(ByVal ModuleName As String,_ ByVal MethodName As String, ByVal Arguments As Object ()) As Object Sub Run() End Interface
ScriptPad contains an implementation of IScriptHost for hosting Script for the .NET Framework script engines, and one for hosting Windows Script engines. I won't go over the Window Script implementation, other than to say it uses the script control to host the engines. Take a look at the code in the implementation for details.
The Script for the .NET Framework implementation is a little more interesting. The constructor of the class takes the language name, the moniker to be used, and the name space to be used in the script. Before I go into the details of the implementation of the constructor, I'll go over some of the new concepts introduced in Script for the .NET Framework engines—in particular the moniker.
Whenever an engine is created, it requires a moniker, which is used as a unique identifier for the engine and is akin to a file name. The RootMoniker must be in the form
<protocol> is a string guaranteed to be unique to the host, and
<path> is a unique sequence of characters recognized by the host. Note that the protocol used in the RootMoniker cannot be a registered protocol handler (such as http: or file:) on the machine on which the engine is running. It is important that the moniker be unique. To avoid the possibility of another application using the same moniker, I recommend using your company's domain name reversed, since that's nearly guaranteed to be unique. For instance:
com.fabrikam//macros. The RootNamespace property specifies the default name space used by the engine. The code generated by the engine is created inside a name space with the given name.
Implementation of the Constructor
Since Script for the .NET Framework provides a number of script engines (well, 3 this time, but the architecture allows for other languages to plug in) the constructor calls a private method, CreateEngine, to get the correct engine, rather than having an implementation of the class for each script language:
Private Sub createEngine() Select Case Language Case "VB", "Visual Basic" myEngine = New Microsoft.VisualBasic.Vsa.VsaEngine() Case "JScript", "JScript.NET" myEngine = New Microsoft.JScript.Vsa.VsaEngine() Case Else Throw New Exception("Unknown Engine") End Select End Sub
Once the engine has been created, the constructor uses the arguments to set the properties on the engine. The engine is now ready to accept code and objects.
Note Each engine has an overloaded constructor that takes the moniker and name space, but I chose to use the distinct properties to more clearly illustrate what's going on.
' Store the langauge Me.Language = language ' Set the moniker to be something relatively unique myEngine.RootMoniker = Moniker ' Set the rootnamespace myEngine.RootNamespace = [NameSpace] ' Create a new instance of the VSA Site myEngine.Site = New VsaSite() ' Get the instance of the site mySite = myEngine.Site ' Set the engine name myEngine.Name = [NameSpace]
The engine is now pretty much initialized; there's a call into the engine to tell it that we're going to initialize it with new code items,
Once the engine has been initialized, we can start adding items to the engine.
The engine exposes an "items collection" (which is a .NET enumeration) containing all the items in the engine, be they code or reference items. Since most scripts will probably want to access the system name space, the hosting class automatically adds a reference to system.dll. This is accomplished by adding a new item to the items collection, setting the item type to be a reference, and giving the item a name. Once the item has been added, all that remains is to set the AssemblyName property to the dll containing the assembly for the reference. In this case, I've just specified "system.dll," since this is always going to be in the Global Assembly Cache (GAC). When adding a reference to something not in the GAC, you would have to provide the fully qualified path.
' Create a reference to system systemRef = myItems.CreateItem("system.dll", _ Microsoft.Vsa.VsaItemType.Reference, Microsoft.Vsa.VsaItemFlag.None) ' Set the assemblyname systemRef.AssemblyName = "system.dll"
The last thing the constructor needs to do is to create a code item for the script code the user writes. There are three types of code items that can be provided by an engine:
- None—a code item with no code whatsoever
- Module—a code item that contains a module (in whatever syntax required by the language engine) and can have event source objects added
- Class—a code item with a class definition and one that can have event source objects added
Since this is a client application that doesn't have any threading requirements, the script hosting class adds a module. This means all the event handlers and procedures will be static. Once the code item has been successfully added, the engine is ready for use.
Step 3: Adding Objects to the Engine
Designing an easy-to-use object model for your users to script against is one of the most difficult tasks to accomplish, so the capacity to add the object model into a script engine easily was a key requirement for Script for the .NET Framework. There are two ways of adding objects into the script engine: GlobalItem item type and AddEventSource. Adding a GlobalItem enables you to add the instance of your object model to the global name space of the engine, so the script users don't have to qualify method calls off that object. For example, if you added an application object model with an alert method as a GlobalItem, script authors could just call alert ("hello world") in their scripts.
A word of caution: Adding global objects can actually end up confusing your users more than helping. For example, we get many complaints from JScript users who can't get alert to work in Windows Script Host, despite the fact that alert is a method on the window object in Microsoft® Internet Explorer. Since they don't have to qualify the object name, they assume, understandably, that alert is part of the JScript language. A GlobalItem can choose to expose its name to the script, just as in Windows Script.
In the ScriptPad example, I've chosen to use event source objects so script writers can respond to events from the Rich Text control. I've written my AddObject method to add event source and global objects (well, to be fair, the signature allows for this, but the implementation doesn't quite do so as yet). The main form adds an instance of the Rich Text control and gives it a name, "Editor" by calling the AddObject method on the script host class. The implementation of AddObject calls the AddEventSource method on the engine so that events will be hooked up; then it calls the AddEvent method on the implementation of IVsaSiteused by the script hosting class. Adding the instance to the site is required, because the script engine will call back to the GetEventSourceInstance method on the site when the engine is run, so the site must know about the instance. The AddEvent method uses a Hashtable to store the instance of the object until it's required for the GetEventSourceInstance method.
The object being passed into AddObject will have a type, so it's important that a reference and an imports statement be added to the engine—so that any code using the object can get at the type information for that object. This is achieved by adding a new reference item to the engine with the AssemblyName, and adding an imports typename to the engine. I took a decidedly low-tech approach to dealing with the imports statement by simply adding an "imports typename" to a string that is used later on to add the imports to the beginning of the script.
Public Overloads Sub AddObject(ByVal Name As String, _ ByVal Type As String, ByRef Instance As System.Object, _ ByVal AssemblyName As String, Optional ByVal Events As Boolean = True) myScript.AddEventSource(Name, Type) mySite.AddEvent(Name, Type, Instance, True) Me.AddReference(Name, AssemblyName) If Language = "VB" Then ImportsScript += "imports" Else ImportsScript += "import" End If ImportsScript += " " & Type End Sub
Step 4: Adding Script Code to the Engine
The engine is now setup ready for any script code that the user writes. To make it simpler to implement, the script host class has an AddCode method that appends script to that already in the engine, and exposes a ScriptSrc property that contains all the script in the engine. ScriptPad provides a very simple script editor as a Windows Form that hosts another Rich Text control. To allow for the future extension of ScriptPad to use the VSA IDE (more on that next month), there's a ScriptEditor class that deals with the displaying of the editor. In this release of ScriptPad, the implementation uses the Windows Form-based editor, but future releases would change the implementation of the class to show the VSA IDE. All access to the script engine is provided by an instance of the ScriptHost class passed into the constructor.
' Simple ScriptEditor Class Public Class ScriptEditor Private host As ScriptHost Private editor As New editor() Public Sub New(ByRef iHost As VsaScriptHost) host = iHost End Sub Public Overridable Function ShowIDE() editor.host = host editor.rtfScript.Text = host.scriptSrc editor.Show() End Function Public Overridable Sub HideIDE() editor.Hide() End Sub End Class
Figure 2. ScriptPad Script Editor
The script editor is just a Rich Text control, but it does provide the ability to check the syntax of the script code and provide error information back to the user. The syntax checker uses the Compile method on the engine and returns any error information in a message box. First, it saves the code back into the engine via the ScriptSrc property, and then checks to see if the engine is running, since compilation is only possible when the engine isn't running. If the engine is running, it is reset, which removes it from the running state and unhooks any event handlers. Once the engine is ready for a compile, the method calls the Compile method, and if the compile is successful, returns the engine to running. The new exception-handling mechanism in Visual Basic .NET makes it much easier to deal with any compilation errors. I, for one, won't mourn the passing of
On Error Goto.
Public Overrides Sub Compile() ' Check to see if the engine is running If myEngine.IsRunning Then myEngine.Reset() End If Try If myEngine.Compile() Then Me.Run() End If Catch e As Microsoft.Vsa.VsaException MsgBox(e.Message) End Try End Sub
Step 5: Run the Code
The script code has now been compiled and run by the engine, so any event handlers in the script will automatically be run for your users. You may also want to be able to provide them with a mechanism to run specific procedures in the script. ScriptPad provides a very simple macros explorer that shows a list of all the procedures running in the script engine.
Figure 3. ScriptPad's Macro Explorer
The ScriptHost class provides a procedures collection that returns all the public static methods in the module. Accessing the information about method in the module is achieved by Reflection (part of .NET). Reflection provides a flexible way to interrogate an assembly for a whole host of information, but most importantly, what methods, arguments, and so on are required to run them.
' Check to see if the engine is running ' If it's not run it so we can get the modules If Not myEngine.IsRunning Then Me.Run() End If ' Return the procedures in the module Dim fullName As String Dim modType As Type Dim methods As MethodInfo() ' Create the fullname of the namespace that contains the method fullName = myEngine.RootNamespace & "." & myEngine.RootNamespace ' Get the type from the assembly modType = myEngine.Assembly.GetType(fullName, True, True) ' Get the methods from the module methods = modType.GetMethods(BindingFlags.Static _ Or BindingFlags.NonPublic Or BindingFlags.Public) ' Return the methods Return methods
I wrote a simple Windows Form that iterates through the procedures collection of the ScriptHost, and displays the names of the procedures in a tree view.
Private Sub macroexplorer_Load(ByVal sender As System.Object, _ ByVal e As System.EventArgs) Handles MyBase.Load Dim scriptModule As String Dim moduleNode As TreeNode Dim scriptProc As MethodInfo ' Add each Module as the root of the tree For Each scriptModule In host.Modules moduleNode = MacroTree.Nodes.Add(scriptModule) ' TODO: Add images here ' Get all the procedures in the module For Each scriptProc In host.Procedures moduleNode.Nodes.Add(scriptProc.Name) Next Next End Sub
To run the selected method, the ScriptHost class provides an Invoke method that takes the name of the method to be called and the arguments. The Invoke method uses reflection to get the method from the assembly created by the script engine, and then calls invoke on the method.
Public Overrides Function Invoke(ByVal ModuleName As String, _ ByVal MethodName As String, ByVal Arguments As Object()) As Object Dim FullName As String Dim ModType As Type Dim method As MethodInfo ' Create the fullname of the namespace that containts the method FullName = myEngine.RootNamespace & "." & ModuleName ' Get the type from the assembly ModType = myEngine.Assembly.GetType(FullName, True, True) ' Get the method from the type method = ModType.GetMethod(MethodName) ' Check to see if we can find the method If Nothing Is method Then ' Can't find the method so throw an exception Throw New Exception("method not found") Else ' Call the method and return any value Return method.Invoke(Nothing, Arguments) End If End Function
When users select the context menu for the tree view item, they can select the run method, or double click, and the macro explorer calls the Invoke method on ScriptHost class with the correct parameters.
Note Unfortunately a bug slipped through in the Beta 2 release of .NET, which means that you have to register the Visual Basic .NET Script engine. This is simple to fix, however: just regsvr32 the vsavbrt7.dll in the Microsoft .NET folder in your windows folder. For example: regsvr32 C:\WINNT\Microsoft.NET\Framework\v1.0.2914\VsaVb7rt.dll
Dealing with Multiple Script Engines
ScriptPad can use Windows Script engines or Script for the .NET Framework engines. As a result, it could use one of a number of script languages other than the interface used by the engines. To abstract this difference from the ScriptPad code, all access to the script engines is through a ScriptHost class.
The download that comes with this article provides an implementation for both Script for the .NET Framework and Windows Script. The ScriptPad code doesn't directly create the ScriptHost class, since I felt it important to hide the details of which hosting interface is related to a particular language name. This is achieved by providing a HostFactory class with the method CreateHost. The ScriptPad code creates an instance of the HostFactory class and calls the CreateHost method with the language name. The CreateHost method looks up the language name and creates the relevant ScriptHost implementation.
Private Function CreateHost(ByVal language As String, _ ByVal Moniker As String, ByVal [NameSpace] As String) As scriptHost Select Case language Case "VB", "Visual Basic", "JScript.NET" Return New VsaScriptHost(language, Moniker, [NameSpace]) Case "JScript", "VBScript" Return New ActiveScriptHost(language) Case Else Throw New Exception("Unknown Engine") End Select End Function
Dealing with Windows Script Engines
Since Windows Script is COM based, any interaction between the script engine and the object model of the application will be via the .NET COM interop layer. The good new is that this is fairly automatic and you don't have to worry about it much. Just use the object, and .NET does the rest for you. There are, however, a couple of gotchas in the Beta 2 release of .NET that you should be aware of
- Windows Form controls don't marshal to IDispatch over interop. Since the Windows Script engines can only deal with IDispatch objects, this would appear to be a show stopper. Luckily, there is a simple work around. Rather than use the default implementation of the Windows Form control, create a class that inherits from the control you want, and use this inherited class in your application. If you take a look at the form definition for script pad, you'll see that the Rich Text control is actually a new instance of rtfWrapper. You don't need to provide any implementation—just a class with the correct inheritance does the trick. Here's the code I used to work around the problem in ScriptPad:
Public Class rtfWrapper Inherits System.Windows.Forms.RichTextBox End Class
- ParamArrays aren't supported in Beta 2, so using the Run method on the script control requires a bit of work. You have to late bind to the control to work around this.
We're very excited to be releasing this beta of Script for the .NET Framework. We hope that you find the new features and design useful and that it meets your requirements. You can get the beta of Script for the .NET Framework by installing the .NET Framework Beta from the MSDN Web site and the Visual Studio for Applications SDK, which contains the VSA IDE. A set of classes to help further integrations is available from the Visual Studio for Applications Web site on MSDN. I highly recommend that you take a look at the Visual Studio for Applications SDK if you are at all interested in using Script for the .NET Framework in your applications.
Here I've only scratched the surface of what's possible with Script for the .NET Framework and Visual Studio for Applications. Over the coming months, however, this column will focus on providing more in-depth coverage of Script for the .NET Framework, Visual Studio for Applications, and JScript .NET. As ever, we would like to hear your feedback, so feel free to contact us on the VSA newsgroups, or at email@example.com.