Hosted Script Execution - oleg-shilo/cs-script.net-framework GitHub Wiki
Further reading:
CS-Script can be hosted by any CLR application. The best way to bring scripting in your application is to add the corresponding NuGet package to your Visual Studio project :
Install-Package CS-Script.bin
or
Install-Package CS-Script
'CS-Script.bin' package contains only binaries (e.g. CSScriptLibrary.dll) and 'CS-Script' contains binaries and sample files. The code samples in this article are consistent with the samples distributed with the NuGet package.
- It is highly recommended that you analyze samples first as they demonstrate the indented use. If you prefer to access sample files online you can find them at this location: https://github.com/oleg-shilo/cs-script/tree/master/Source/NuGet/content/net46.
CSScriptLibrary.dll is the actual assembly implementing CS-Script as a class library. It is targeting .NET v4.0/4.5. However the package from the CodePlex Releases page always contains CSScriptLibrary.dll builds for earlier versions of .NET (<cs-script>\lib\Bin).
Setting up Evaluator
Any application hosting CS-Script engine can execute C# code containing either fully defined types or code fragments (e.g. methods). It is important to note that CS-Script is neither a compiler nor evaluator. It is rather a runtime environment that relies for the code compilation by the standard compiling toolset available with any .NET or Mono installation. The first compiling services CS-Script integrated was CodeDom. It is the very original compiler-as-service that was available starting from the first release of .NET.
While many may not consider CodeDom as 'true compiler-as-service', in reality, it actually is. The problem with CodeDom API is that it's inconvenient in require a great deal of manual runtime configuration. And it is exactly what CS-Script is addressing. Later on some alternative proprietary compiler-as-service solutions became available. First Mono Evaluator and later Roslyn (currently still Beta). Both demonstrated a completely different approach towards what good scripting API should look like. Thus all three compiling platforms are mutually inconsistent and only partially overlapping in with respect to functionality.
And this is where CS-Script comes to the rescue. It provides one unified generic interface transparent to the underlying compiler technology. You can have your script code executed by ether of three supported compilers without affecting your hosting code.
The code below demonstrates creating (compiling) a delegate.
var sqr = CSScript.Evaluator
.CreateDelegate(@"int Sqr(int a)
{
return a * a;
}");
var r = sqr(3);
By default CSScript.Evaluator
references Mono compiler. However you can change this behavior globally by re-configuring the default compiler:
CSScript.EvaluatorConfig.Engine = EvaluatorEngine.Mono;
CSScript.EvaluatorConfig.Engine = EvaluatorEngine.Roslyn;
CSScript.EvaluatorConfig.Engine = EvaluatorEngine.CodeDom;
Alternatively you can access the corresponding compiler via a dedicated member property:
CSScript.MonoEvaluator.CreateDelegate(...
CSScript.RoslynEvaluator.CreateDelegate(...
CSScript.CodeDomEvaluator.CreateDelegate(...
Every time a CSScript.*Evaluator
property is accessed a new instance of the corresponding evaluator is created and returned to the caller. Though this can be changed by re-configuring the evaluator access type to return the reference to the global evaluator object:
CSScript.EvaluatorConfig.Access = EvaluatorAccess.Singleton;
Executing the scripts
The evaluator allows executing code containing definition a type (or multiple types):
Assembly script = CSScript.Evaluator
.CompileCode(@"using System;
public class Script
{
public int Sum(int a, int b)
{
return a+b;
}
}");
Alternativelly you can compile code containing only method(s). In this case Evaluator will wrap the method code into a class definition with name DynamicClass
:
Assembly script = CSScript.Evaluator
.CompileMethod(@"int Sum(int a, int b)
{
return a+b;
}");
Further access to the script members can be simplified by using Evaluator.Load*
, which compiles the code and returns the instance of the first class in compiled assembly:
dynamic script = CSScript.Evaluator
.LoadMethod(@"int Product(int a, int b)
{
return a * b;
}");
int result = script.Product(3, 2);
Note that in the code below the method Product
is invoked with 'dynamic' keyword, meaning that the host application cannot do any compile-time checking in the host code. In many cases it is OK, though sometimes it is desirable that the script object was strongly typed. The easiest way to achieve this is to use interfaces:
public interface ICalc
{
int Sum(int a, int b);
}
...
ICalc script = (ICalc)CSScript.Evaluator
.LoadCode(@"using System;
public class Script : ICalc
{
public int Sum(int a, int b)
{
return a+b;
}
}");
int result = script.Sum(1, 2);
Not that you can also use an Interface alignment (duck-typing) technique, which allows 'aligning' the script object to the interface even if it is not implementing this interface. It is achieved by evaluator wrapping the script object into dynamically generated proxy of the interface (e.g. ICals) type:
ICalc script = CSScript.Evaluator
.LoadCode<ICalc>(@"using System;
public class Script
{
public int Sum(int a, int b)
{
return a+b;
}
}");
Evaluator also allows compiling method scripts into class-less delegates:
var sqr = CSScript.Evaluator
.CreateDelegate<int>(@"int Sqr(int a)
{
return a * a;
}");
int result = sqr(3);
In the code above CreateDelegate
returns MethodDelegate<T>
, which is semi-dynamic by nature. It is strongly typed by return type and dynamically typed (thanks to 'params') by method parameters:
public delegate T MethodDelegate<T>(params object[] paramters);
Note, because CLR cannot distinguish between arguments of type "params object[]" and any other array (e.g. string[]). You may need to do one extra step to pass array args. You will need to wrap them as an object array:
var getFirst = CSScript.Evaluator
.CreateDelegate<string>(@"string GetFirst(string[] values)
{
return values[0];
}");
string[] values = "aa,bb,cc".Split(',') ;
//cannot pass values directly
string first = getFirst(new object[] { values });
Though if strongly typed delegate is preferred then you can use LoadDelegate
instead:
Func<int, int, int> product = CSScript.Evaluator
.LoadDelegate<Func<int, int, int>>(
@"int Product(int a, int b)
{
return a * b;
}");
int result = product(3, 2);
Script can automatically access all types of the host application without any restrictions according the types visibility (public vs. private). This is the evaluator by default references (for the script) all loaded assemblies of the current AppDomain. However custom assembly referencing is also available:
IEvaluator evaluator = CSScript.Evaluator;
string code = "<some C# code>";
evaluator.Reset(false); //clear all ref assemblies
evaluator.ReferenceAssembly(Assembly.GetExecutingAssembly())
.ReferenceAssembly(@"c:\some_dir\myAsm.dll")
.ReferenceAssemblyByName("System.Xml")
.ReferenceAssemblyByNamespace("System.Windows.Forms")
.ReferenceAssemblyOf(this)
.ReferenceAssemblyOf<string>()
.ReferenceAssembliesFromCode(code)
.ReferenceDomainAssemblies();
dynamic script = evaluator.LoadCode(code);
The code above is a pseudo code that is not particularly useful but it does demonstrates the referencing technique very well.
Method ReferenceAssembliesFromCode
is particularly interesting as it utilizes CS-Script ability to analyse script code and find out what assemblies the script needs to reference. Read this article for more details.
Asynchronous execution
CS-Script allows asynchronous script execution via IEvaluator extension methods. The extensions cover all interface members and automatically support all compilation platforms (e.g. Roslyn, Mono):
async void button_Click(object sender, EventArgs e)
{
var product = await CSScript.Evaluator
.CreateDelegateAsync<int>(
@"int Product(int a, int b)
{
return a * b;
}");
textBox.Text = product(3, 2).ToString();
}
Maintainability
While all three supported compiling services normalized via Evaluator interface some of the critical features only available via dedicated Evaluators. Thus CodeDom Evaluator is a 'champion' of all compilers as no others can match its flexibility. Thus it is the only compiler platform that supports script debugging:
CSScript.EvaluatorConfig.DebugBuild = true;
CSScript.EvaluatorConfig.Engine = EvaluatorEngine.CodeDom;
dynamic script = CSScript.Evaluator
.LoadCode(@"using System;
using System.Diagnostics;
public class Script
{
public int Sum(int a, int b)
{
Debug.Assert(false, ""Testing..."");
return a+b;
}
}");
var r = script.Sum(3, 4); //triggers the assertion
Another very important CodeDom feature that is not available with other compilers is the ability to unload already loaded scripts. Though it is outside of this article scope and you can read more about unique CodeDom features in [Choosing Compiler Engine] article.
Script Unloading - avoiding memory leaks
Both Mono and Roslyn exhibit a fundamental by-design constrain. A script being executed (evaluated) must be loaded in a caller AppDomain. This model allows the fasted compilation as but it has a price tag attached to it. Once loaded script (or in fact any assembly) it cannot be unloaded. Thus leads to the situation when the AppDomain is stuffed with abandoned assemblies and a new one is added every single time a new script executed. This behavior is a well known design flaw in the CLR architecture. It has been reported, acknowledged by Microsoft as a problem and ... eventually dismissed as "hard to fix". Instead of fixing it MS offered a work around - "a dynamically loaded assembly should be loaded into remote temporary AppDomain, which can be unloaded after the assembly routine execution".
It works. However this work around imposes some serious constrains on the type model of the script and host application. All types that are to be passed across AppDomain boundaries of the script and the host application must be either serializable or inherited from MarshalByRefObject.
A convenient script unloading based on "Remote AppDomain" work around was available in CS-Script from the first release. Currently it is part of CSScript.Native API. CSScript.Native API is based on CodeDom compilation, which is capable of building the assembly outside of the calling AppDomain. This makes it ideal for solving "unloading challenge". Porting the same solution to CSScript.Evaluator is not straightforward as the solution requires a proper file-based script assemblies and neither Mono nor Roslyn is able to emit such assemblies. Thus unloading for CSScript.Evaluator is a completely independent solution implemented as a set of extension methods (CSScriptLibrary.EvaluatorRemoting) to the CSSCript.IEvaluator interface. The NuGet package 'cs-script' contains a set of samples demonstrating how to use all of these extensions. Below is just one of them:
var sum = CSScript.Evaluator
.CreateDelegateRemotely<int>(@"int Sum(int a, int b)
{
return a+b;
}");
int result = sum(15, 3);
sum.UnloadOwnerDomain();
The unloading extensions cover only a part of CSScript.Evaluator API. Some methods are not compatible with the AppDomain unloading approach. The table below illustrates the support level:
Remote loading/unloading support
IEvaluator Methods | Supported in CodeDom/Mono/Roslyn |
---|---|
CreateDelegate | yes |
CreateDelegate | yes |
LoadCode | yes |
LoadCode | no1 |
LoadFile | yes |
LoadFile | no1 |
LoadMethod | yes |
LoadMethod | no1 |
LoadDelegate | no2 |
CompileCode | not applicable3 |
CompileMethod | not applicable3 |
1 - The return type (transparent CLR proxy) by design is incompatible with invoking via 'dynamic'.
2 - The return type (Assembly) cannot be passed back to the caller domain as it is not serializable by design.
3 - If supported, will require emitting a proxy delegate into the called domain defeating the purpose of not loading anything to the caller domain.
Note that working with remote AppDomains always (even without scripting) has performance penalties for any objects crossing AppDomain boundaries and emitting transparent proxies. In some cases it may still be acceptable but not if Roslyn is used as a compiler technology. Roslyn services have an enormous loading overhead (anything between 3-5 seconds). Even such a long delay can be OK if it happens only on first load (non-unloading scenario) but when unloading is used Roslyn initializes itself every single time the script is executed. There is a good chance it will improve when Roslyn scripting is out of beta but currently (Feb 2016) Roslyn is just not practical enough for script unloading scenarios.
Conclusion
This article briefly described the hosting API but you will find more samples in the downloadable package (on Releases page) in <cs-script>\samples\Hosting directory. The hosting API itself is made compiler transparent so in terms of development effort it doesn't matter which compiler you use. However it doesn't mean that all compilers are offering the same functionality. The next article Choosing Compiler Engine will help you to chose the compiler most suitable for your task.