Skip to content

Hosted Script Execution

Matthias Klaey edited this page Oct 26, 2021 · 26 revisions
Since porting CS-Script on .NET 5 the documentation is still in the process of being refined and cleared from the legacy content.
Thus, it may contain some minor inaccuracies until both statuses below are set to full and complete or this disclaimer is removed.
.NET 5 accuracy status: full
Review/update status: completed

Further reading:

Hosted script execution

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

The package contains CSScriptLib.dll assembly, which is compiled for .NET Standard 2.0 target framework. Meaning it can be hosted on both .NET Framework and .NET Core CLR.

It is highly recommended that you analyze the samples first as they demonstrate the indented use:


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 interpreter/evaluator. It is rather a runtime environment that relies for the code compilation by the standard compiling toolset available with any .NET 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 Framework.

While many may not consider CodeDom as 'true compiler-as-service', 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, some alternative proprietary compiler-as-service solutions became available. First Mono Evaluator and then Roslyn. Both demonstrated a completely different approach and understanding of what good scripting API should look like.

Thus, all three scripting platforms were inconsistent with respect to each other and only partially overlapping functionality wise. And this is where CS-Script comes to the rescue. It provides one unified generic interface transparent to the underlying compiler technology.

  • With the latest development in the .NET ecosystem, CodeDom and Mono have been discontinued. Thus, CS-Script implements its own equivalent of CodeDom (read more).

You can have your script code executed by ether of three supported compilers without affecting your hosting code.

The code below demonstrates creating a scripted method.

dynamic script = CSScript.Evaluator
                         .LoadMethod(@"int Product(int a, int b)
                                       {
                                           return a * b;
                                       }");
    
int result = script.Product(3, 2);

By default, CSScript.Evaluator references Roslyn compiler. However, you can change this behavior globally by re-configuring the default compiler:

CSScript.EvaluatorConfig.Engine = EvaluatorEngine.Roslyn;
CSScript.EvaluatorConfig.Engine = EvaluatorEngine.CodeDom;

Alternatively, you can access the corresponding compiler via a dedicated member property:

CSScript.RoslynEvaluator.LoadMethod(...
CSScript.CodeDomEvaluator.LoadMethod(...

You may want to choose one or another engine depending on scripting scenario. In most of the cases Roslyn engine (default) is a good choice as it does not require any other dependencies on the hosting OS except .NET 5 runtime. Whereas CodeDom is more flexible but requires .NET SDK (read more).

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

Creating objects

The evaluator allows executing code containing definition of a type (or multiple types):

Assembly printer = CSScript.Evaluator
                           .LoadCode(@"using System;
                                       public class Printer
                                       {
                                           public void Print() =>
                                               Console.WriteLine(""Printing..."");
                                       }");

Alternatively, you can compile code containing only method(s). In this case Evaluator will wrap the method code into a class definition (with class name DynamicClass):

Assembly calc = CSScript.Evaluator
                        .CompileMethod(@"int Product(int a, int b)
                                         {
                                             return a * b;
                                         }");

Using raw script assembly is possible (e.g. via Reflection) but not very convenient. Thus, 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 the compiled assembly (for almost every Compile*() there is an equivalent Load*()):

dynamic calc = CSScript.Evaluator
                       .LoadMethod(@"int Product(int a, int b)
                                     {
                                         return a * b;
                                     }");

int result = calc.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 the script defines only the interface methods but not the whole class. It is achieved by evaluator wrapping the script object into dynamically generated proxy of the interface (e.g. ICalc) type:

ICalc calc = new_evaluator.LoadMethod<ICalc>("int Sum(int a, int b) => a+b;");
var result = calc.Sum(7, 3);
Creating delegates

Evaluator also allows compiling method scripts into class-less delegates:

 var sum = new_evaluator.CreateDelegate(@"int Sum(int a, int b)
                                              => a + b;");

 int result = (int)sum(7, 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[] parameters);

Note, when using CreateDelegate, 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);
Referencing assemblies

Script can automatically access all types of the host application without any restrictions but according the types visibility (public vs. private). Thus, the evaluator references (by default) all loaded assemblies of the current AppDomain. Meaning that from the script you can access all the assemblies of the current AppDomain of your host application.

Additionally, you can also reference any custom assembly:

IEvaluator evaluator = CSScript.Evaluator;
string code = "<some C# code>"; // IE ICalc implementation

evaluator.Reset(false); //clear all ref assemblies

dynamic script = evaluator
    .ReferenceAssembliesFromCode(code)
    .ReferenceAssembly(Assembly.GetExecutingAssembly())
    .ReferenceAssembly(Assembly.GetExecutingAssembly().Location)
    .ReferenceAssemblyByName("System")
    .ReferenceAssemblyByNamespace("System.Xml")
    .TryReferenceAssemblyByNamespace("Fake.Namespace", out var resolved)
    .ReferenceAssemblyOf(this)
    .ReferenceAssemblyOf<XDocument>()
    .ReferenceDomainAssemblies()
    .LoadCode<ICalc>(code);

int result = script.Sum(13, 2);
Referencing other dependencies

While you can reference the assemblies from the hosting code, an alternative mechanism for referencing dependencies from the script itself is also available. This method is the only dependency definition mechanism available for CS-Script CLI execution since there is no hosting code.

This referencing approach is based on the CS-Script special in-script directives //css_*. These directives are placed directly in the code and allow:

  • Referencing assemblies with //css_reference
  • Importing other scripts with //css_include While CodeDom engine supports //css_include fully, Roslyn engine does it with limitations. Thus, Roslyn API does not allow multi module scripting. Meaning that when importing the scripts, CS-Script simply merges imported scripts with the primary script into a single combined script. And this in turn can potentially lead to C# syntax errors (e.g. namespaces collisions).

Script Unloading - avoiding memory leaks

Prior .NET Core 3.0, CLR had a fundamental by-design constrain/flaw that was preventing loaded assemblies from being unloaded. This in turn had a dramatic usability impact on scripting, as the script being executed must be loaded in a caller AppDomain as an assembly.

  • Thus, once script (as in fact any assembly) loaded 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".

    A convenient script unloading based on "Remote AppDomain" work around was available in CS-Script from the first release.

Eventually, after 17 years of delay the assembly unloading became available with the introduction of the AssemblyLoadingContext.IsCollectible. CS-Script offers convenient extension method Assembly.Unload() for that:

dynamic script = CSScript.Evaluator
                         .With(eval => eval.IsAssemblyUnloadingEnabled = true)
                         .LoadMethod(@"public object func()
                                       {
                                           return new[] {0,5};
                                       }");

var result = script.func();

var asm_type = (Type)script.GetType();

asm_type.Assembly.Unload();

Note, that assembly unloading needs to be enabled first by setting IEvaluator.IsAssemblyUnloadingEnabled= true. It is disabled by default because this .NET feature comes with the limitations. One of them is that collectible (uploadable) assemblies cannot be referenced from any dynamically loaded assembly (e.g. scripts). Due to this limitation, even though minor, you should enable unloading only if it is truly required.


Conclusion

This article briefly described the hosting API but you will find more samples here.

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 both compilers are offering the same functionality. The next article Choosing Compiler Engine will help you to choose the compiler most suitable for your task.

Clone this wiki locally