梦想博客

C#中使用C#脚本的思考

· 1685 words · 4 minutes to read
Tags: hot-update

在很久以前,一直想实现对C#的脚本功能的支持

在我的引导项目(Aura)上,其使用了CSScriptLibrary来实现对C#脚本的支持

Aura为Mabinogi的开源服务端模拟器,后来其github项目收到了DMCA所以无法继续更新

似乎也可以妥善实现热更新的功能,但是由于该项目较老(2010年左右?)且该库已停止维护,框架为.net framework4.0无法在linux等系统中使用

在机缘巧合下,烤鸭大佬给了我新的思路,即roslyn此为C#原生的编译器,顺着这个思路我开始了研究

看此文前请注意,您必须掌握一些编码的技巧

代码 🔗

  • 一共仅列出3个文件的代码,供实现基础的功能

ScriptManager.cs 🔗

using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;

namespace CombinedCompilation
{
    public class ScriptManager
    {
        private Dictionary<string, ScriptExecutor> scripts;

        public ScriptManager()
        {
            scripts = new Dictionary<string, ScriptExecutor>();
        }

        // 加载和编译脚本
        public void LoadScript(string scriptId, string code, string className, string methodName)
        {
            if (scripts.ContainsKey(scriptId))
            {
                Console.WriteLine($"Script with ID {scriptId} is already loaded.");
                return;
            }

            var executor = new ScriptExecutor();
            executor.CompileAndExecute(code, className, methodName);
            scripts[scriptId] = executor;
        }

        // 卸载脚本
        public void UnloadScript(string scriptId)
        {
            if (scripts.ContainsKey(scriptId))
            {
                scripts[scriptId].Unload();
                scripts.Remove(scriptId);
            }
            else
            {
                Console.WriteLine($"Script with ID {scriptId} is not loaded.");
            }
        }

        // 执行已经加载的脚本
        public void ExecuteScript(string scriptId, string className, string methodName)
        {
            if (scripts.ContainsKey(scriptId))
            {
                var executor = scripts[scriptId];
                // 再次执行时不需要重新编译
                executor.CompileAndExecute("", className, methodName);
            }
            else
            {
                Console.WriteLine($"Script with ID {scriptId} is not loaded.");
            }
        }
    }
}

ScriptLoadContext.cs 🔗

using System;
using System.Collections.Generic;
using System.Linq;
using System.Runtime.Loader;
using System.Text;
using System.Threading.Tasks;

namespace CombinedCompilation
{
    public class ScriptLoadContext: AssemblyLoadContext
    {
        public ScriptLoadContext() : base(isCollectible: true) { }
    }
}

ScriptExecutor.cs 🔗

using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using Microsoft.CodeAnalysis.CSharp;
using Microsoft.CodeAnalysis;
using System.Runtime.Versioning;

namespace CombinedCompilation
{
    public class ScriptExecutor
    {
        private ScriptLoadContext loadContext;

        public ScriptExecutor()
        {
            loadContext = new ScriptLoadContext();
        }

        public void CompileAndExecute(string code, string className, string methodName)
        {
            var sw = Stopwatch.StartNew();
            var syntaxTree = CSharpSyntaxTree.ParseText(code, new CSharpParseOptions(LanguageVersion.Preview),encoding:Encoding.UTF8);

            // 添加引用
            var references = AppDomain.CurrentDomain.GetAssemblies()
               .Where(a => !a.IsDynamic && !string.IsNullOrWhiteSpace(a.Location))
               .Select(a => MetadataReference.CreateFromFile(a.Location))
               .Cast<MetadataReference>().ToList();


            var compilation = CSharpCompilation.Create(
                "DynamicAssembly",
                new[] { syntaxTree },
                references,
                new CSharpCompilationOptions(
                    OutputKind.DynamicallyLinkedLibrary,
                    optimizationLevel: OptimizationLevel.Debug,
                    allowUnsafe: true,
                    checkOverflow:true,
                    concurrentBuild: true,
                    platform: Platform.AnyCpu,
                    usings: new string[] { "System" }
                ));

            using (var ms = new MemoryStream())
            {
                var emitResult = compilation.Emit(ms);
                Console.WriteLine($"Compilation: {sw.ElapsedMilliseconds}ms");


                if (emitResult.Success)
                {
                    ms.Seek(0, SeekOrigin.Begin);
                    File.WriteAllBytesAsync($"{className}.il", ms.ToArray());
                    // 加载程序集到独立的上下文中
                    var assembly = loadContext.LoadFromStream(ms);
                    Console.WriteLine($"Assembly Load: {sw.ElapsedMilliseconds}ms-》{assembly.FullName}/{assembly.ImageRuntimeVersion}");

                    // 动态调用编译生成的类和方法
                    var type = assembly.GetType(className);
                    var instance = Activator.CreateInstance(type);
                    var method = type.GetMethod(methodName);
                    method.Invoke(instance, null);

                    Console.WriteLine($"Execution: {sw.ElapsedMilliseconds}ms");
                }
                else
                {
                    foreach (var diagnostic in emitResult.Diagnostics)
                    {
                        Console.WriteLine(diagnostic.ToString());
                    }
                }
            }
        }

        // 卸载程序集
        public void Unload()
        {
            if (loadContext != null)
            {
                loadContext.Unload();
                loadContext = null;
                GC.Collect();
                GC.WaitForPendingFinalizers();
                Console.WriteLine("Assembly has been unloaded.");
            }
        }
    }
}

在var assembly = loadContext.LoadFromStream(ms);这里似乎有点问题,其显示的.net框架为0.0.0,但是实测在linux中可以使用(ubuntu22.04)

Program.cs 🔗

using CombinedCompilation;

ScriptManager manager = new ScriptManager();

string scriptId1 = "Script1";
string code1 = @"
                using System;
                public class DynamicClass
                {
                    public void Execute()
                    {
                        Console.WriteLine(""This is the hot-updated code in .NET 8 - Script 1!"");
                    }
                }
            ";

// 加载和执行第一个脚本
manager.LoadScript(scriptId1, code1, "DynamicClass", "Execute");

Thread.Sleep(5000);

// 卸载第一个脚本
manager.UnloadScript(scriptId1);

//string scriptId2 = "Script2";
string code2 = @"
                 using System;
                public class DynamicClass
                {
                    public void Execute()
                    {
                        Console.WriteLine(""This is the hot-updated code in .NET 8 - Script 2! "");
                    }
                }
            ";

// 加载和执行第二个脚本
manager.LoadScript(scriptId1, code2, "DynamicClass", "Execute");

// 卸载第二个脚本
manager.UnloadScript(scriptId1);

思考 🔗

在如上代码中,我粗略的实现了在C#中对C#脚本的支持,但是距离实际项目中的使用仍然存在着不小的差距

以下想法仅供思考,就对于aura的项目而言

在mabinogi中,item,NPC,quest,region,dungeon,ai,etc..这些每个都是一个单独的脚本

如果在项目中直接硬编码写死,无疑会产生很大的障碍,每次编译时间较久,且不方便修改和编写

我们可以将scripts的目录做如下的区分

  • items
  • quests
  • monsters
  • npcs
  • regions
  • ais
  • dungeons

每个目录对应每一个功能,然后通过manager.LoadScript(“items”, …, “Item”, “AutoLoad”);来实现自动加载,使用反射来获取全部的特性,然后通过函数字典进行操作

例如,如下是我的item中的某一个脚本

[ItemScript(85564, 85619, 85763, 85776)]
public class ApPotionItemScript : ItemScript
{
	public override void OnUse(Creature creature, Item item, string parameter)
	{
		var ap = item.MetaData1.GetShort("AP");
		if (ap <= 0)
		{
			Send.MsgBox(creature, L("Invalid potion."));
			return;
		}

		creature.AbilityPoints += ap;

		Send.StatUpdate(creature, StatUpdateType.Private, Stat.AbilityPoints);
		Send.AcquireInfo(creature, "ap", ap);
	}
}

那么我就可以进行如下操作

var attr = this.GetType().GetCustomAttribute<ItemScriptAttribute>();
if (attr == null)
{
	Log.Error("ItemScript.Init: Missing ItemScript attribute.");
	return false;
}

foreach (var itemId in attr.ItemIds)
	//添加函数字典

return true;

至于reload,那就更简单了

粗略的方法是,重新编译item->编译成功->从item函数字典中卸载->卸载item->挂载item->反射加载函数字典

如果想优雅一点那就监听文件变化,例如FileSystemWatcher,如果items中的脚本发生了变化则进行重载,否则跳过

其他 🔗

如上配置较为复杂,且平时项目中可能使用不到,但是基础的使用仍然是有可能的

针对于此,还有另一种的简单思路,如Eval-Expression.NET

简单的demo如下

int result = Eval.Execute<int>("X + Y", new { X = 1, Y = 2});

Categories