Скриптинг в C# или динамическое выполнение в runtime

Привет, Хабр!

Думаю, немногие знают, что в C# есть штука на наподобие eval из других языков. Благодаря Roslyn API, можно во время выполнения скомпилировать и выполнить код на C#. Пример использования Вы можете посмотреть в моей реализации REPL-а для C#.

Впервые с такой штукой, как REPL, я познакомился, когда трогал питона. В мире .NET есть похожая вещь под названием C# Interactive (CSI). Довольно удобная штука, однако у нее есть один большой минус — она входит в состав инструментов Visual Studio, так что без установки VS, не получится ее использовать, а чтобы запускать ее без запуска VS, и вовсе надо лезть в ее недра (а точнее, через консоль запустить C:\Program Files (x86)\Microsoft Visual Studio\2019\Community\Common7\Tools\VsDevCmd.bat), что может не быть достаточно удобным решением.

Есть еще такие проекты, как dotnet-script и cs-script (они работают через Microsoft.CodeAnalysis.CSharp.Scripting), но у них есть фатальный недостаток — они написаны не мной. Вот и появилась мысль написать свой корявый велосипед, но со своими фичами (которые тоже коряво работают)!. После недолгих поисков, мой взор упал на сие чудо: Microsoft.CodeAnalysis.CSharp.Scripting. Из плюсов — удобный API, возможность выполнять код без классов и namespace-ов.

Для начала, нужно поставить нугет пакет Microsoft.CodeAnalysis.CSharp.Scripting и сделать using

using Microsoft.CodeAnalysis.CSharp.Scripting;
using Microsoft.CodeAnalysis.Scripting;

CSharpScript — статичный класс, который поможет нам создать скрипт, включает 3 метода:

  • Create — создает Script с указанным кодом и параметрами, который можно будет в последствии скомпилировать и запустить

  • RunAsync — который компилирует, выполняет переданный код и возвращает ScriptState

  • EvaluateAsync — выполняет код и возвращает результат выполнения

CSharpScript.Create можно использовать, когда вам нужно предварительно скомпилировать скрипт и часто вызывать его.

var script = CSharpScript.Create("System.Console.WriteLine(\"Hello from script\")");
script.Compile();
await script.RunAsync();

Eсли не вызвать Compile (), то код будет скомпилирован при первом вызове.

Для удобства можно создать ScriptOptions, в котором можно будет добавить namespace-ы и reference-ы (можно также добавить статические классы, на подобии using static).

  var options = ScriptOptions.Default
            .AddImports("System", "System.IO", "System.Collections.Generic",
                "System.Console", "System.Diagnostics", "System.Dynamic",
                "System.Linq", "System.Text",
                "System.Threading.Tasks")
            .AddReferences("System", "System.Core", "Microsoft.CSharp");

 CSharpScript.Create("Console.WriteLine(\"Hello from script\")", options);

Но здесь есть один момент — ScriptOptions почему-то не ограничивают доступные namespace-ы. Этакий whitelist, как я изначально подумал, возможно, просто не до конца разобрался.

CSharpScript.RunAsync возвращает ScriptState, его можно дополнить вызвав ContinueWithAsync, который скомпилирует, выполнит код и вернет новый объект ScriptState. Можно повторно запустить скрипт, обратившись к свойству Script. Для получения результата, есть свойство ReturnValue.

ScriptState state = await CSharpScript.RunAsync("int x = 5;");
state = await state.ContinueWithAsync("x + 1");
Console.WriteLine(state.ReturnValue); // 6

У объекта state можно посмотреть объявленные переменные, а так же полученный exception

foreach(var variable in state.Variables)
{
	Console.WriteLine($"{variable.Name} - {variable.Value}");
}

С помощью CSharpScript.Create можно создать делегат из скрипта, который будет запускать скрипт при вызове

var script = CSharpScript.Create>("x => x+1");
Console.WriteLine(await script.CreateDelegate().Invoke(1)); // 2

А так же, можно скомпилировать лямбда-выражение в виде строки, используя CSharpScript.EvaluateAsync (или другими способами, которые были выше)

var deleg = await CSharpScript.EvaluateAsync>("x => x * 2");
Console.WriteLine(deleg(5)); // 10

Это может быть удобно для сериализации и десериализации лямбда-выражений (мой скудный ум не смог придумать юзкейса для этого, но я встречал людей, которым такая штука была нужна).

У методов CSharpScript есть параметры globals и globalsType (globalsType можно не указывать, оно возьмет тип у globals), с помощью которого можно указать объект, члены которого будут глобально доступны (У CSharpScript.Create можно только указать globalsType, и в script.RunAsync () передать globals).

var res = await CSharpScript.EvaluateAsync("X+Y", globals: new GlobalValues());
Console.WriteLine(res); // 100

public class GlobalValues 
{
	public int X = 25;
	public int Y = 75;
}

Ниже представлены тесты:

fb73d369b8d1c3bb4cf0a1ec3c755eb5.png

Не думаю, что они очень точны, но помогут примерно понять скорость выполнения. С кодом можете ознакомится по ссылке.

Заключение

Microsoft.CodeAnalysis.CSharp.Scripting, довольно удобная шутка, для runtime выполнения C# кода. Можно использовать например в своем движке, или для предоставления способа модификации, без надобности создания .net проекта и его сборки.

Топ 5 популярных реп в github, которые используют данный пакет:

aa0629c11c3dfc3fdcdd2e927fd5e810.png

Дополнительные примеры можно найти по ссылкам внизу:

https://github.com/dotnet/roslyn/blob/main/docs/wiki/Scripting-API-Samples.md

https://github.com/dotnet/roslyn/tree/a7319e2bc8cac34c34527031e6204d383d29d4ab/src/Scripting

Надеюсь, моя первая статья не показалось слишком скучной, и я смог как-то вам помочь.

Хорошего вам дня!

© Habrahabr.ru