Introduction
You know what I miss about the pre .NET days? Script! I liked creating a little script file to do little tasks for me, or to test a small piece of code without having to create a new project or solution. I like having one nice little file to deal with and clean up after, not a solution folder, a project folder and the resulting bin and obj folders. I long for those days, which is why I created Dot Net Script.
What is Dot Net Script? Basically, it's nothing more than a console application that reads an XML document from a .dnml file (Dot Net Markup Language.yea, I made this up). This XML document has sub-elements that hold information about assembly references, the language the code is written in, and the actual code to get compiled and executed. The console application, which I call the script engine, reads the XML text and parses out the required data. It then uses classes from the CSharp, VisualBasic, and CodeDom namespaces to compile the code and load the resulting assembly into memory. The script engine then uses reflection to execute the entry function in the generated assembly. When the user closes the console window, the script engine closes and the in-memory assembly goes out of scope and is cleaned up by the GC. No DLLs or EXEs are ever created.
.NET Markup Language
So let's take a look at what Dot Net Markup Language looks like. It's pretty simple actually. Below is an example Dot Net script. I'll go over each element in the XML document.
<dnml>
<reference assembly="System.Windows.Forms.dll" />
<language name="C#" />
<scriptCode><![CDATA[
using System.Windows.Forms;
public class Test
{
public static void Main()
{
Console.WriteLine("This is a test");
MessageBox.Show("This is another test");
Test2 two = new Test2();
two.Stuff();
}
}
public class Test2
{
public void Stuff()
{
Console.WriteLine("Instance call");
}
}
]]></scriptCode>
</dnml>
The document XML element is called <dnml>
(can you guess what it stands for?). Inside this element are three different elements that you use to define how the script gets compiled.
First, there is the <reference>
element, which only has one attribute called 'assembly
'. The 'assembly
' attribute holds the assembly name (including file extension) that you want to reference. A .dnml document can have many <reference>
elements in it, which correspond to the list of references that you would add to a project in Visual Studio. For each reference that your code will need to execute, you should add a <reference assembly="" />
element.
As far as assembly probing is concerned, any assembly that you reference that is located in the GAC will automatically be found by the CLR. But if you have an assembly that is not in the GAC, things are a little different. Let's say you have an assembly called Common.dll which is not GAC'd. In order for your Dot Net script to execute, Common.dll needs to be in two places. First, it needs to be in the same folder as your .dnml file. The second place Common.dll needs to be is in the folder where the script engine EXE is located. I'm trying to clean this up, but as of now, non-GAC'd assemblies will need to reside in the two different folders.
The next element is called <language>
and has one attribute called 'name
'. A .dnml document can only have one language element. The two possible values for the 'name
' attribute are 'C#
' and 'VB
', which I hope are self-explanatory.
The last element is called <scriptCode>
, which has a CDATA
XML element. Inside this element lies the code that will be executed when you execute the .dnml file. There are a few interface rules that you'll need to follow in order to use it though. First, since this is really just plain 'ole C# or VB.NET, all methods and fields must be inside of a class. Second, you can define as many classes as you want, BUT one of those classes must have a public
'static
' / 'Shared
' method called 'Main
', that returns nothing and takes no input parameters. This will be the entry method that is the script engine searches for via reflection, and when found, will be called. Also, it doesn't matter which class you put the 'Main
' method in, because the script engine will loop through each type defined until it finds a Main
function.
How Does the Script Engine Work?
Most of the code in the script engine is pretty straightforward, so I won't go over every aspect of it. The only really interesting part is a class called AssemblyGenerator
, which only has one method, called CreateAssembly()
. This method does all the work to compile and create a new assembly, as shown below.
CodeDomProvider codeProvider = null;
if (code.IsCSharp)
codeProvider = new CSharpCodeProvider();
else
codeProvider = new VBCodeProvider();
ICodeCompiler compiler = codeProvider.CreateCompiler();
The first thing I do is declare an instance of the CodeDomProvider
. This is the base class for both the CSharpCodeProvider
and the VBCodeProvider
classes. You can use these language specific XxxProvider
objects to create a CodeGenerator
object, which is used to generate source code based on its underlying CodeDom object graph that you create. You can create a CodeParser
object, which could be used to populate a CodeDom
object graph based on a string
of source that you pass to it (currently in 1.1, this only returns null
). The XxxProvider objects can also be used to create a CodeCompiler
, which is how I use it here. The CodeCompiler
class is what I use to compile the source code from the .dnml file into a new assembly.
So, based on the language that is specified in the .dnml file, I create the appropriate XxxCodeProvider
object. And from this object, I request a CodeCompiler
instance, which is language specific.
CompilerParameters compilerParams = new CompilerParameters();
compilerParams.CompilerOptions = "/target:library /optimize";
compilerParams.GenerateExecutable = false;
compilerParams.GenerateInMemory = true;
compilerParams.IncludeDebugInformation = false;
compilerParams.ReferencedAssemblies.Add("mscorlib.dll");
compilerParams.ReferencedAssemblies.Add("System.dll");
foreach (string refAssembly in code.References)
compilerParams.ReferencedAssemblies.Add(refAssembly);
Next, I create a CompilerParameters
object. This class basically encapsulates all the command line arguments that you would use if you manually compiled an assembly with csc.exe (C# compiler) or vbc.exe (VB.NET compiler). One line which is specifically important is the property GenerateInMemory
, which I set to true
. This will make sure that when the code is compiled, no files will be created as a finished assembly. The generated assembly will only reside in memory.
The last part of this code adds all the references the code requires to the CompilerParameters
. By default, I add references to mscorlib.dll and system.dll. I then add a reference to each assembly that is defined in the .dnml file via a <reference>
elements.
CompilerResults results = compiler.CompileAssemblyFromSource(
compilerParams, code.SourceCode));
if (results.Errors.Count > 0)
{
foreach (CompilerError error in results.Errors)
DotNetScriptEngine.LogAllErrMsgs("Compine Error:"+error.ErrorText);
return null;
}
Next, I call CodeCompiler.CompileAssemblyFromSource
, which takes the CompilerParameters
object and a string
variable that holds the actual source code to get compiled. The returned object is of type CompilerResults
. This object contains a collection of CompileError
objects if the compile failed, which I use to show the user what went wrong with the compile.
Assembly generatedAssembly = results.CompiledAssembly;
return generatedAssembly;
}
If the code compiles successfully, the CompilerReslts
object will hold a reference to the newly compiled and created Assembly
object. I grab this object and return it to the calling method.
Once the assembly has been successfully created and returned, the script engine will use reflection to loop through each of the created Types, and look for a static
method called 'Main
'. If it finds one, it will use reflection again to execute it. If the engine does not find a Main
function, it will return an error to the user explaining the problem.
Finishing Touches
The Dot Net Script Engine can also create and remove file associations for .dnml files to the script engine. This means that once this association has been made, all you have to do to execute a .dnml file is to double click it. When you do this, the script engine will be executed and a command line argument is passed to it with the path and name of the .dnml file. The script engine will read the file and process the XML accordingly.
In order to create a file association between .dnml files and the Dot Net Script Engine, just double click DotNetScriptEngine.exe. When it runs with no command line arguments, it will automatically create the file association on your server. If you run DotNetScriptEngine.exe via the command prompt and pass in the 'remove
' argument, the engine will automatically remove the file associations from your server.
Additional Updates
Update 3/18/04: Version 2.0.0.1
This version was so large and has changed so much that I figured it warranted a major version number increase. The following is a list of fixes and enhancements.
An app.config file was added in order to remove some of the optional XML elements from the .dnml file that was commonly repeated: such as waitForUserAction
, method entry point, script language, and commonly referenced assemblies. I also added the ability to add new languages to the script engine, so that you can use to write Dot Net Script files in any language that defines its own 'CodeProvider
' class (more on this later). The values defined in the app.config file act line the machine config. That is, these are base values, but can be overridden by the values set in the dnml file. The values in the dnml file have the highest priority and will be used by the script engine. But if your .dnml script file does not define any settings, then the values from the app.config file will be used. This allows you to only define the actual script in the .dnml file.
User preferences config section: A user preferences section has been added to the app.config file. This section defines three settings. The default language, the script entry point, and the wait for user action flag. The default language can be used to set what language the script will be compiled for if the language element is not defined in the dnml file. The entry point is the method that the script engine will call if the dnml file does not define an entry point. The waitForUserAction
flag is a bool
that determines if the console window will stay open and wait for a crlf when the script is finished running. If this is not defined in the dnml file, the value in the config file will be used by the script engine. Below is an example of this section.
<userPreferences defaultLanguage="C#" entryPoint="Main"
waitForUserAction="true" />
Referenced Assemblies config section: This section lets you define assemblies that are required for the script to run. Any assembly defined in this section will be compiled into every script that is run. Just the assembly name is required, not the full assembly path. Below is an example of this section.
<referencedAssemblies>
<assembly path="System.dll" />
<assembly path="System.Messaging.dll" />
<assembly path="System.Messaging.dll" />
<assembly path="System.Security.dll" />
</referencedAssemblies>
Supported Languages config section: This section lets you dynamically add new languages to the script engine without having to recompile the engine. The name
attribute is the name that is entered in the dnml file or the defaultLanguage
attribute in the user preferences config section. The assembly
attribute is the full path and file name of the assembly that contains the language's implementation of the code provider. The codeProviderName
attribute is the name, including namespace of the language's code provider class. Take a look at the LateBindCodeProvider()
function in the AssemblyGenerator
class to see how I added this functionality to the script engine.
<supportedLanguages>
<language name="JScript"
assembly="C:\WINNT\Microsoft.NET\Framework\v1.1.4322\Microsoft.JScript.dll"
codeProviderName="Microsoft.JScript.JScriptCodeProvider" />
<language name="J#"
assembly="c:\winnt\microsoft.net\framework\v1.1.4322\vjsharpcodeprovider.dll"
codeProviderName="Microsoft.VJSharp.VJSharpCodeProvider" />
</supportedLanguages>
Update 2/18/04: Version 1.0.2.0
I made a fix that Charlie pointed out with the registration of the .dnml file extension file type with the DotNetScriptEngine.exe.
Also added an optional entryPoint
attribute to the language
element in the dnml XML format. This would allow the user to specify an assembly entry point other than a method called "Main
". If the entryPoint
attribute is filled out with a method name, that method will become the entry point. If the entryPoint
attribute is missing or empty, then "Main
" will become the assembly entry point.
The language element can be defined in one of the following three ways:
<language name="C#" /> Main() will be the
entry method to the assembly
<language name="C#" entryPoint"" /> Main() will
be the entry method to the assembly
<language name="C#" entryPoint"Stuff" /> Stuff()
will be the entry method to the assembly
I also added an optional <waitForUserAction>
element to the .dnml XML format. This is another new feature that lets you define in the .dnml file if you want the console window to remain open after the script is finished running. The waitForUserAction
element is an optional element. If it is not included in the .dnml file, then the window will remain open. The value
attribute can hold the values of 'true
' or 'false
'. If true
, the window will remain open. If false
, the console window will close immediately after the script if finished running. This would allow you to chain different script files together into one batch file.
Possible Ways to Use This Element
--nothing-- Console window will remain open after script has run
<waitForUserAction value="true"/> Console window will
remain open after script has run
<waitForUserAction value="True"/> Console window will
remain open after script has run
<waitForUserAction value="TRUE"/> Console window will
remain open after script has run
<waitForUserAction value="false"/> Console window will
close after script has run
<waitForUserAction value="False"/> Console window will
close after script has run
<waitForUserAction value="FALSE"/> Console window will
close after script has run
And finally, I added the ability to return an int
from the dot net script back to the calling process, cmd or batch file. Now, there are now two different ways to define the return value for the entry method of the script. You can define it as void
, or you can define it as int
. If you use void
, then nothing will be returned from the script. If you return an int
, then the script engine will return the value of the int
to the calling process.
Example of two different dnml script entry methods:
public static void Main()
{
return;
}
public static int Main()
{
return 5;
}
Public Shared Sub Main()
'...do stuff
return
End Sub
//The script engine will return a 5 when this script is called.
Public Shared Function Main() as Integer
'...do stuff
return 5
End Function
License
This article has no explicit license attached to it, but may contain usage terms in the article text or the download files themselves. If in doubt, please contact the author via the discussion board below.
A list of licenses authors might use can be found here.