梦想博客

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

calendar 2024/10/24
refresh-cw 2024/10/24
1856字,4分钟
tag hot-update;

前言 🔗

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

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

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

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

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

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

代码 🔗

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

ScriptManager.cs 🔗

 1using System;
 2using System.Collections.Generic;
 3using System.Linq;
 4using System.Text;
 5using System.Threading.Tasks;
 6
 7namespace CombinedCompilation
 8{
 9    public class ScriptManager
10    {
11        private Dictionary<string, ScriptExecutor> scripts;
12
13        public ScriptManager()
14        {
15            scripts = new Dictionary<string, ScriptExecutor>();
16        }
17
18        // 加载和编译脚本
19        public void LoadScript(string scriptId, string code, string className, string methodName)
20        {
21            if (scripts.ContainsKey(scriptId))
22            {
23                Console.WriteLine($"Script with ID {scriptId} is already loaded.");
24                return;
25            }
26
27            var executor = new ScriptExecutor();
28            executor.CompileAndExecute(code, className, methodName);
29            scripts[scriptId] = executor;
30        }
31
32        // 卸载脚本
33        public void UnloadScript(string scriptId)
34        {
35            if (scripts.ContainsKey(scriptId))
36            {
37                scripts[scriptId].Unload();
38                scripts.Remove(scriptId);
39            }
40            else
41            {
42                Console.WriteLine($"Script with ID {scriptId} is not loaded.");
43            }
44        }
45
46        // 执行已经加载的脚本
47        public void ExecuteScript(string scriptId, string className, string methodName)
48        {
49            if (scripts.ContainsKey(scriptId))
50            {
51                var executor = scripts[scriptId];
52                // 再次执行时不需要重新编译
53                executor.CompileAndExecute("", className, methodName);
54            }
55            else
56            {
57                Console.WriteLine($"Script with ID {scriptId} is not loaded.");
58            }
59        }
60    }
61}

ScriptLoadContext.cs 🔗

 1using System;
 2using System.Collections.Generic;
 3using System.Linq;
 4using System.Runtime.Loader;
 5using System.Text;
 6using System.Threading.Tasks;
 7
 8namespace CombinedCompilation
 9{
10    public class ScriptLoadContext: AssemblyLoadContext
11    {
12        public ScriptLoadContext() : base(isCollectible: true) { }
13    }
14}

ScriptExecutor.cs 🔗

 1using System;
 2using System.Collections.Generic;
 3using System.Diagnostics;
 4using System.Linq;
 5using System.Text;
 6using System.Threading.Tasks;
 7using Microsoft.CodeAnalysis.CSharp;
 8using Microsoft.CodeAnalysis;
 9using System.Runtime.Versioning;
10
11namespace CombinedCompilation
12{
13    public class ScriptExecutor
14    {
15        private ScriptLoadContext loadContext;
16
17        public ScriptExecutor()
18        {
19            loadContext = new ScriptLoadContext();
20        }
21
22        public void CompileAndExecute(string code, string className, string methodName)
23        {
24            var sw = Stopwatch.StartNew();
25            var syntaxTree = CSharpSyntaxTree.ParseText(code, new CSharpParseOptions(LanguageVersion.Preview),encoding:Encoding.UTF8);
26
27            // 添加引用
28            var references = AppDomain.CurrentDomain.GetAssemblies()
29               .Where(a => !a.IsDynamic && !string.IsNullOrWhiteSpace(a.Location))
30               .Select(a => MetadataReference.CreateFromFile(a.Location))
31               .Cast<MetadataReference>().ToList();
32
33
34            var compilation = CSharpCompilation.Create(
35                "DynamicAssembly",
36                new[] { syntaxTree },
37                references,
38                new CSharpCompilationOptions(
39                    OutputKind.DynamicallyLinkedLibrary,
40                    optimizationLevel: OptimizationLevel.Debug,
41                    allowUnsafe: true,
42                    checkOverflow:true,
43                    concurrentBuild: true,
44                    platform: Platform.AnyCpu,
45                    usings: new string[] { "System" }
46                ));
47
48            using (var ms = new MemoryStream())
49            {
50                var emitResult = compilation.Emit(ms);
51                Console.WriteLine($"Compilation: {sw.ElapsedMilliseconds}ms");
52
53
54                if (emitResult.Success)
55                {
56                    ms.Seek(0, SeekOrigin.Begin);
57                    File.WriteAllBytesAsync($"{className}.il", ms.ToArray());
58                    // 加载程序集到独立的上下文中
59                    var assembly = loadContext.LoadFromStream(ms);
60                    Console.WriteLine($"Assembly Load: {sw.ElapsedMilliseconds}ms-》{assembly.FullName}/{assembly.ImageRuntimeVersion}");
61
62                    // 动态调用编译生成的类和方法
63                    var type = assembly.GetType(className);
64                    var instance = Activator.CreateInstance(type);
65                    var method = type.GetMethod(methodName);
66                    method.Invoke(instance, null);
67
68                    Console.WriteLine($"Execution: {sw.ElapsedMilliseconds}ms");
69                }
70                else
71                {
72                    foreach (var diagnostic in emitResult.Diagnostics)
73                    {
74                        Console.WriteLine(diagnostic.ToString());
75                    }
76                }
77            }
78        }
79
80        // 卸载程序集
81        public void Unload()
82        {
83            if (loadContext != null)
84            {
85                loadContext.Unload();
86                loadContext = null;
87                GC.Collect();
88                GC.WaitForPendingFinalizers();
89                Console.WriteLine("Assembly has been unloaded.");
90            }
91        }
92    }
93}

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

Program.cs 🔗

 1using CombinedCompilation;
 2
 3ScriptManager manager = new ScriptManager();
 4
 5string scriptId1 = "Script1";
 6string code1 = @"
 7                using System;
 8                public class DynamicClass
 9                {
10                    public void Execute()
11                    {
12                        Console.WriteLine(""This is the hot-updated code in .NET 8 - Script 1!"");
13                    }
14                }
15            ";
16
17// 加载和执行第一个脚本
18manager.LoadScript(scriptId1, code1, "DynamicClass", "Execute");
19
20Thread.Sleep(5000);
21
22// 卸载第一个脚本
23manager.UnloadScript(scriptId1);
24
25//string scriptId2 = "Script2";
26string code2 = @"
27                 using System;
28                public class DynamicClass
29                {
30                    public void Execute()
31                    {
32                        Console.WriteLine(""This is the hot-updated code in .NET 8 - Script 2! "");
33                    }
34                }
35            ";
36
37// 加载和执行第二个脚本
38manager.LoadScript(scriptId1, code2, "DynamicClass", "Execute");
39
40// 卸载第二个脚本
41manager.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中的某一个脚本

 1[ItemScript(85564, 85619, 85763, 85776)]
 2public class ApPotionItemScript : ItemScript
 3{
 4	public override void OnUse(Creature creature, Item item, string parameter)
 5	{
 6		var ap = item.MetaData1.GetShort("AP");
 7		if (ap <= 0)
 8		{
 9			Send.MsgBox(creature, L("Invalid potion."));
10			return;
11		}
12
13		creature.AbilityPoints += ap;
14
15		Send.StatUpdate(creature, StatUpdateType.Private, Stat.AbilityPoints);
16		Send.AcquireInfo(creature, "ap", ap);
17	}
18}

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

 1var attr = this.GetType().GetCustomAttribute<ItemScriptAttribute>();
 2if (attr == null)
 3{
 4	Log.Error("ItemScript.Init: Missing ItemScript attribute.");
 5	return false;
 6}
 7
 8foreach (var itemId in attr.ItemIds)
 9	//添加函数字典
10
11return true;

至于reload,那就更简单了

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

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

其他 🔗

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

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

简单的demo如下

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

分类