Introduction
Ever had a need to run an external script from your application, and wish your script had the power and speed of a compiled language like C#? This "how to" shows you how to read in a C# code file, compile it, check for errors, and execute the code. You can even pass in objects from within your compiled application for the external code to use.
Background
From time to time, I have had need to extend a program I wrote by having it execute a script, but wanted the script to use data from within the application, logging, etc. This approach allows me to use the power of C#. In production code, I would use checksums on the C# code files to make sure only authorized scripts could be compiled and run.
Using the code
The core of how this works is found in the class CodeDOMProcessor.cs in the attached ZIP file. The project shows how to use this class to read in a code file designed for this example.
It should be noted that the using statements in an external code file use the file name (e.g. "System.dll" instead of "System"). If the reference is not in the GAC, the reference must be the fully qualified file name of the assembly file. One user has reported he did not need the ".dll", but in the initial development for this article (using VS2010), failure to include it caused runtime errors as the referenced assemblies could not be resolved. The errors received on compile are:
CompilerResults CompileResults = DOMProvider.CompileAssemblyFromSource(DOMCompilerParams, pCodeToCompile); Colourised in 0ms
Error# [CS0006] - [Metadata file 'System' could not be found] Line# [0] Column# [0].
Error# [CS0006] - [Metadata file 'System.Windows.Forms' could not be found] Line# [0] Column# [0].
Error# [CS0006] - [Metadata file 'System.Runtime.Serialization' could not be found] Line# [0] Column# [0].
It does not hurt to add it, and if you do not add it, an d get such exceptions, then add the ".dll".
Writing an External C# Code File
Obviously, the code you want to execute hast be as compilable as the code you would have in your project. The code shown below exists in the example project ZIP file, RunExternal.zip, as Test.cs. It includes a set of "using" declarations (which have no meaning to the compiler), and the expected namespace, class, and other statements, declarations, and code - even comments. I have found that the compiler expects type names to be the full name, leading me to believe the IDE expands that "under the covers".
<pre style="background: white; color: black; font-family: Consolas; font-size: 13px;">using System.dll;
using System.Windows.Forms.dll;
using System.Runtime.Serialization.dll;
namespace JeffJones.ExternalCode
{
public class JJScriptTest
{
public System.Int32 TestMemberInt = 0;
private System.String m_Message = "";
private System.String m_Caption = "";
public void ShowMessage(System.String pMessage, System.String pCaption, RunExternal.TestObject pTestObject)
{
m_Message = pMessage;
m_Caption = pCaption;
System.String String2Show = pMessage + System.Environment.NewLine + pTestObject.ComputerName;
System.Windows.Forms.MessageBox.Show(String2Show, pCaption, System.Windows.Forms.MessageBoxButtons.OK, System.Windows.Forms.MessageBoxIcon.Exclamation);
}
public void ShowMessage2(RunExternal.TestObject pTestObject)
{
if ((m_Message.Length > 0) & (m_Caption.Length > 0))
{
System.String String2Show = m_Message + System.Environment.NewLine + pTestObject.ComputerName;
System.Windows.Forms.MessageBox.Show(String2Show, m_Caption, System.Windows.Forms.MessageBoxButtons.OK, System.Windows.Forms.MessageBoxIcon.Exclamation);
}
else
{
throw new System.ArgumentException("Message and/or caption missing.");
}
}
public System.String Message2Show
{
get
{
return m_Message;
}
}
public System.String Caption
{
get
{
return m_Caption;
}
set
{
m_Caption = value;
}
}
}
} Colourised in 42ms
I read the code in and go line by line using the CodeFile.cs class instance method, "SetAndCompileCSCode()". In that method, I pull out the "using" declarations to get the assembly references, and do not include those lines in the code to compile. For this exercise, I look for the first class declaration to get a "main class" name (a value required by the compiler) and use it. A refinement of the code might find all class names to allow the user to choose the main class for compiling.
The CodeProvider Object
In the CodeDOMProcessor.cs class, the Microsoft.CSharp.CSharpCodeProvider object (DOMProvider) is used to compile the code. The System.CodeDom.Compiler.CompilerParameters object (DOMCompilerParams) is used with the CSharpCodeProvider object to provide the referenced assemblies (the "using" declarations at the top of the external code). The constructor for the CSharpCodeProvider takes a dictionary that allows the programmer to specify the version of the .NET framework to use. For this example project, .NET 4.0 is used.
<pre style="background: white; color: black; font-family: Consolas; font-size: 13px;">DOMProviderOptions = new Dictionary<String, String>();
DOMProviderOptions.Add(COMPILER_VERSION_KEY, COMPILER_VERSION_SUPPORTED);
DOMProvider = new CSharpCodeProvider(DOMProviderOptions);Colourised in 1ms
Getting Referenced Assemblies
Having already parsed out the assemblies from the code, it is time to add those assemblies to the compiler. Remember, the references that are not in the GAC must be a fully qualified file name to the assembly file. The code below shoes how the assemblies are added.
<pre style="background: white; color: black; font-family: Consolas; font-size: 13px;">DOMProvider = new CSharpCodeProvider(DOMProviderOptions);
DOMCompilerParams = new CompilerParameters();
if (pReferencedAssemblies != null)
{
if (pReferencedAssemblies.Count > 0)
{
foreach (String RefAssembly in pReferencedAssemblies)
{
if (RefAssembly != null)
{
if (RefAssembly.Length > 0)
{
DOMCompilerParams.ReferencedAssemblies.Add(RefAssembly);
} else
{
ReturnVal.Add(String.Format("A reference file was empty.{0}", Environment.NewLine));
}
} else
{
ReturnVal.Add(String.Format("A reference file was null.{0}", Environment.NewLine));
}
}
}
} Colourised in 14ms
What if the code misses a needed reference? You can now check to see references you know are needed are present, and if not, add them.
<pre style="background: white; color: black; font-family: Consolas; font-size: 13px;">if (!DOMCompilerParams.ReferencedAssemblies.Contains("System.dll"))
{
DOMCompilerParams.ReferencedAssemblies.Add("System.dll");
}
if (!DOMCompilerParams.ReferencedAssemblies.Contains("System.Windows.Forms.dll"))
{
DOMCompilerParams.ReferencedAssemblies.Add("System.Windows.Forms.dll");
}
if (!DOMCompilerParams.ReferencedAssemblies.Contains("System.Runtime.Serialization.dll"))
{
DOMCompilerParams.ReferencedAssemblies.Add("System.Runtime.Serialization.dll");
}Colourised in 5ms
And finally, add the reference of the external code itself.
<pre style="background: white; color: black; font-family: Consolas; font-size: 13px;">DOMCompilerParams.ReferencedAssemblies.Add(System.Reflection.Assembly.GetEntryAssembly().Location);Colourised in 1ms
Now that the compiler is selected, the framework is chosen, and the referenced assemblies added, the destination for the compiled code should be selected.
In Memory or a DLL?
The compiler can generate the results in memory or create a file. You can set compiler options, choose to include debug information, and specify the main class. The code shown below uses an in-memory choice (which oddly enough has a file name).
<pre style="background: white; color: black; font-family: Consolas; font-size: 13px;">DOMCompilerParams.GenerateInMemory = true;
DOMCompilerParams.GenerateExecutable = false;
DOMCompilerParams.CompilerOptions = "/optimize";
DOMCompilerParams.IncludeDebugInformation = true;
DOMCompilerParams.MainClass = pMainClassName;
CompilerResults CompileResults = DOMProvider.CompileAssemblyFromSource(DOMCompilerParams, pCodeToCompile);Colourised in 3ms
The System.CodeDom.Compiler.CompilerResults instance contains the results of compiling, which we examine below.
Compiling and the Results
The CompilerResults instance that is returned has an Errors collection. You can iterate through the collection to find the errors that prevented successful compilation. The CompileError child object in the collection gives the line and column number for each error. Note that when counting line numbers, comment lines do not get counted in the compiler's line count.
<pre style="background: white; color: black; font-family: Consolas; font-size: 13px;">if (CompileResults.Errors.Count != 0)
{
foreach (CompilerError oErr in CompileResults.Errors)
{
ReturnVal.Add(String.Format("Error# [{0}] - [{1}] Line# [{2}] Column# [{3}].{4}",
oErr.ErrorNumber.ToString(), oErr.ErrorText, oErr.Line.ToString(),
oErr.Column.ToString(), Environment.NewLine));
}
} Colourised in 6ms
You could keep a separate List<String> of code lines the way the compiler does if you wanted and take the line number and column number to show the user the exact location of the error.
Hi Good Looking Code -Tell Me About Yourself
The compiler has much to tell you about what the code represents. This information is useful for making decisions about using the external code, or giving the user choices about how to use it. You can get information on:
- Assemblies
- Constructors and Parameters
- Members (including Default Members)
- Fields
- Methods, Return Values, and Parameters
The code below shows ho this example uses the objects to glean information on the object represented by the code. You could create and populate class instances that contain information gleaned from constructors, methods, members, properties, etc. and their parameters instead of passing back these generalized descriptions.
<pre style="background: white; color: black; font-family: Consolas; font-size: 13px;">Type[] ObjectTypes = CompileResults.CompiledAssembly.GetTypes();
if (ObjectTypes.Length > 0)
{
pFullTypeName = ObjectTypes[0].FullName;
Object CompiledObject = CompileResults.CompiledAssembly.CreateInstance(pFullTypeName);
Type CompiledType = CompiledObject.GetType();
pModuleName = CompiledType.Module.ScopeName;
ConstructorInfo[] TempConstructors = CompiledType.GetConstructors();
foreach (ConstructorInfo TempConstructor in TempConstructors)
{
String StringToAdd = "";
if (TempConstructor.Name == ".ctor")
{
StringToAdd = "void " + ObjectTypes[0].Name;
}
else
{
StringToAdd = "void " + TempConstructor.Name;
}
String ParmString = "";
if (TempConstructor.Module.ScopeName.Equals(pModuleName))
{
ParameterInfo[] TempConstructorParam = TempConstructor.GetParameters();
foreach (ParameterInfo TempParam in TempConstructorParam)
{
ParmString += String.Format("{0} {1}, ", TempParam.ParameterType.FullName, TempParam.Name);
}
}
StringToAdd += "(" + ParmString + ")";
pConstructors.Add(StringToAdd);
}
MemberInfo[] TempDefaultMembers = CompiledType.GetDefaultMembers();
if (TempDefaultMembers.Length > 0)
{
foreach (MemberInfo TempMember in TempDefaultMembers)
{
if (TempMember.Module.ScopeName.Equals(pModuleName))
{
String StringToAdd = "";
StringToAdd = String.Format("{0} {1}, ", TempMember.ReflectedType.FullName, TempMember.Name);
pMembers.Add(StringToAdd);
}
}
}
FieldInfo[] TempFields = CompiledType.GetFields();
if (TempFields.Length > 0)
{
foreach (FieldInfo TempField in TempFields)
{
if (TempField.Module.ScopeName.Equals(pModuleName))
{
String StringToAdd = "";
StringToAdd = String.Format("{0} {1}, ", TempField.ReflectedType.FullName, TempField.Name);
pFields.Add(StringToAdd);
}
}
}
MemberInfo[] TempMembers = CompiledType.GetMembers();
if (TempMembers.Length > 0)
{
foreach (MemberInfo TempMember in TempMembers)
{
if (TempMember.Module.ScopeName.Equals(pModuleName))
{
String StringToAdd = "";
StringToAdd = TempMember.ToString();
pMembers.Add(StringToAdd);
}
}
}
MethodInfo[] TempMethods = CompiledType.GetMethods();
foreach (MethodInfo TempMethod in TempMethods)
{
if ((TempMethod.Module.ScopeName.Equals(pModuleName)) && (!TempMethod.IsSpecialName))
{
String StringToAdd = "";
StringToAdd = String.Format("{0} {1}, ", TempMethod.ReturnType.FullName, TempMethod.Name);
ParameterInfo[] TempParams = TempMethod.GetParameters();
String ParmString = "";
foreach (ParameterInfo TempParam in TempParams)
{
String ParamName = TempParam.Name;
String ParamTypeName = TempParam.ParameterType.FullName;
Object DefaultValue = TempParam.DefaultValue;
if (DefaultValue.ToString().Length == 0)
{
ParmString += String.Format("{0} {1}, ", ParamTypeName, ParamName);
}
else
{
ParmString += String.Format("{0} {1}={2}, ", ParamTypeName, ParamName, DefaultValue.ToString());
}
}
if (ParmString.EndsWith(", "))
{
ParmString = ParmString.Substring(0, ParmString.Length - 2);
}
StringToAdd += "(" + ParmString + ")";
pMethods.Add(StringToAdd);
}
}
PropertyInfo[] TempProperties = CompiledType.GetProperties();
if (TempProperties.Length > 0)
{
foreach (PropertyInfo TempProperty in TempProperties)
{
if (TempProperty.Module.ScopeName.Equals(pModuleName))
{
String StringToAdd = "";
StringToAdd = String.Format("{0} {1}, ", TempProperty.PropertyType.FullName, TempProperty.Name);
if (TempProperty.CanRead && TempProperty.CanWrite)
{
StringToAdd += " (get/set)";
}
else if (!TempProperty.CanRead && TempProperty.CanWrite)
{
StringToAdd += " (set ONLY)";
}
else if (TempProperty.CanRead && !TempProperty.CanWrite)
{
StringToAdd += " (get ONLY)";
}
else
{
}
pProperties.Add(StringToAdd);
}
}
}
} Colourised in 113ms
Here are what the results look like displayed in the sample app:
Executing the Code
Once you have successfully compiled the code, all that is left is to execute it. That last part is quite easy. You create the instance of the compiled assembly, get a reference to the method to execute, then invoke the method with an Object array of parameters, and capture the return value. The code below shows how that is done for this example. For the example, there is only one assembly. An external file could have more.
<pre style="background: white; color: black; font-family: Consolas; font-size: 13px;">Type[] ObjectTypes = CompileResults.CompiledAssembly.GetTypes();
if (ObjectTypes.Length > 0)
{
String FullTypeName = ObjectTypes[0].FullName;
Object CompiledObject = CompileResults.CompiledAssembly.CreateInstance(FullTypeName);
MethodInfo CompiledMethod = CompiledObject.GetType().GetMethod(pExecutionMethodName);
Object ReturnValue = CompiledMethod.Invoke(CompiledObject, pMethodParameters);
} Colourised in 6ms
Now you have external, uncompiled C# code that was loaded, compiled, and run from a C# program.
What About Security?
If the developer is the only one providing and maintaining the script, clearly using a DLL that is "late bound" or read by reflection is the better route as compared to an editable script. However, if the scripts are created by, or maintained by, someone other than developers (such as support teams or others who may know C# or VB.NET well enough to maintain a script), then ensuring a malicious script is prevented becomes important. That type of scenario is the context of this section on security.
A reasonable and prudent question might be, "How do I keep someone from supplying a malicious script and my program getting blamed for the damage it does?"
There are several approaches, one of which I will cover here.
The Checksum Method - When you are supplying the script, you can checksum each line, plus a secret string only your program knows. The first line of your code is a comment with the total checksum value you calculated when the script was made. If the checksum the program calculates when reading in the code (less that first line) matches the value in the file, the file is valid. If not, the code is not compiled nor executed.
Conclusion
When you think of the versatility offered by being able to create or provide scripts for a program you have written, this capability offers a new way to extend your application.
Points of Interest
This also works with VB.NET code by using the Microsoft.VisualBasic.VBCodeProvider to process the VB.NET code.
History
When Who What
======= == ======================================================
05/08/2014 JDJ Genesis.
05/09/2014 JDJ Update an expansion of text.