Скриптинг в 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<int>("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<Func<int,int>>("x => x+1");
Console.WriteLine(await script.CreateDelegate().Invoke(1)); // 2

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

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

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

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

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

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

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

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

Заключение

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

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

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

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

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

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

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

Средняя зарплата в IT

120 000 ₽/мес.
Средняя зарплата по всем IT-специализациям на основании 7 040 анкет, за 1-ое пол. 2021 года Узнать свою зарплату
Реклама
AdBlock похитил этот баннер, но баннеры не зубы — отрастут

Подробнее

Комментарии 15

    +4

    Кстати для заинтересованных в таких вещах существует открытый и кроссплатформенный проект dotnet/interactive. Я его использовал в юпитере, но можно использовать embedded kernel, как здесь.

      +2
      Самое важное не рассказали: можно ли, и как именно, передавать «внешние» объекты и функции внутрь скрипта и использовать их из него (догадываюсь, что возможно можно через Variables, может быть можно и как-нибудь красивее).
        0

        Одна из перегрузок CSharpScript.Create принимает параметром тип глобального объекта.
        Его методы будут доступны из скрипта глобально.(если он не статический то в Create и RunAsync можно сам объект передать)


        Script<object> script = CSharpScript.Create(File.ReadAllText(Program.options.Script), options, typeof (ScriptHost), null);
        script.RunAsync(scriptHost, null, new CancellationToken()).Wait();
        //Уже в скрипте вызываем метод от глобального объекта
        StartSession("some-args");

        код декомпилированный(не помню где исходники проекта), но смысл должен быть понятен.


        Собственно сам студийный C# Interactive работает по такому же принципу поидее, но там глобальный тип это Console.(можно WriteLine писать прям так)

          0

          Возможно, там просто в ScriptOptions указан. Если добавить в него System.Console, то можно будет просто написать WriteLine(...), точно так же, как при использовании "using static System.Console".

            0

            вы имели в виду WriteLine наверно :)


            там несколько способов помоему.
            Потому что я использовал явно вариант и с ScriptOptions и с globalsType аргументами.

              0

              Да, спасибо, исправил.


              Про вариант с globalsType учту.

          0

          Спасибо, что напомнили, постараюсь добавить в статью

            0
            Добавил.
            +4

            Я обычно "скриптингом" на C# занимаюсь через Powershell. Очень удобно, когда нужен простенький GUI, например:


            helloworldGui.ps1
            $Assem = (
                “System.Windows.Forms”,
                "System.Drawing",
                "System.Core",
                "System.Data",
                "System.Xml"
                )
            $Source = @”
            using System;
            using System.Collections.Generic;
            using System.Data;
            using System.IO;
            using System.Linq;
            using System.Reflection;
            using System.Text.RegularExpressions;
            using System.Threading.Tasks;
            using System.Windows.Forms;
            
            namespace Infor.LMS.Utils.TranslationSearch
            {
            
                public class Form1: Form
                {
                    /// <summary>
                    /// Required designer variable.
                    /// </summary>
                    private System.ComponentModel.IContainer components = null;
            
                    /// <summary>
                    /// Clean up any resources being used.
                    /// </summary>
                    /// <param name="disposing">true if managed resources should be disposed; otherwise, false.</param>
                    protected override void Dispose(bool disposing)
                    {
                        if (disposing && (components != null))
                        {
                            components.Dispose();
                        }
                        base.Dispose(disposing);
                    }
            
                    #region Windows Form Designer generated code
            
                    /// <summary>
                    /// Required method for Designer support - do not modify
                    /// the contents of this method with the code editor.
                    /// </summary>
                    private void InitializeComponent()
                    {
                        this.label1 = new System.Windows.Forms.Label();
                        this.SuspendLayout();
                        // 
                        // label1
                        // 
                        this.label1.AutoSize = true;
                        this.label1.Location = new System.Drawing.Point(10, 10);
                        this.label1.Name = "label1";
                        this.label1.Font = new System.Drawing.Font("Segoe UI", 20F, System.Drawing.FontStyle.Regular, System.Drawing.GraphicsUnit.Point);           
                        this.label1.Size = new System.Drawing.Size(148, 13);
                        this.label1.Text = "Hello world!";
            
                        // 
                        // Form1
                        // 
                        this.AutoScaleDimensions = new System.Drawing.SizeF(6F, 13F);
                        this.AutoScaleMode = System.Windows.Forms.AutoScaleMode.Font;
                        this.ClientSize = new System.Drawing.Size(300, 200);
                        this.Controls.Add(this.label1);
                        this.Margin = new System.Windows.Forms.Padding(2);
                        this.Name = "Form1";
                        this.Text = "Hello World!";
            
                        this.ResumeLayout(false);
                        this.PerformLayout();
            
                    }
            
                    #endregion
            
                    private System.Windows.Forms.Label label1;
            
                    //End of codegen
            
                    public Form1()
                    {
                        InitializeComponent();
                    }
                }
            
                public static class Program 
                {
                    public static void Start()
                    {
                        Application.EnableVisualStyles();
                        Application.SetCompatibleTextRenderingDefault(false);
                        Application.Run(new Form1());
                    } 
                }
            }
            “@
            
            Add-Type -ReferencedAssemblies $Assem -TypeDefinition $Source -Language CSharp 
            
            iex "[Infor.LMS.Utils.TranslationSearch.Program]::Start()"

              0
              более развитый инструмент предоставляет пакет DynamicExpresso
                0
                > для сериализации и десериализации лямбда-выражений

                Главное не принимать от клиентской стороны лямбда выражения, иначе это готовый remote code execution
                  +1

                  Да, вот для этого и искал, ограничение для доступных namespace-ов и reference-ов, но увы, не нашел. Можно у автора sharplab.io подсмотреть

                  0
                    0

                    С Microsoft.CodeAnalysis.CSharp.Scripting только одна проблема, если на .Net5 попытаться запаблишить как self-contained с флагом PublishSingleFile=true, то оно не работает (по крайней мере сейчас).

                      0
                      Спасибо за информацию!

                    Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.

                    Самое читаемое