前言 🔗
在很久以前,一直想实现对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});