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