記事の概要
今回はソースコードをオンライン実行する仕組みを実際に作成してみようと思います。
本来、C#はVisualStudioやdotnetコマンドを用いてソースコードをコンパイルする必要がありますが、コンパイル用のライブラリも用意されていて、今回はそれを使った方法で実装していきます。
プロジェクトの作成
記事を短くするためにクライアント側はPostmanで代用したいと思います。
サーバーはASP.NET Core WebAPIのテンプレート使って作成します。
nugetで必要なパッケージのインストール
以下が今回必要なパッケージです。
Microsoft.CodeAnalysis.CSharp
APIを作成していく
コンパイル処理とインスタンス化処理を作成していく
ソースコードをコンパイルする際は、ソース内で参照されるDLLも指定する必要があり、今回は標準的な5つのDLLのみを固定で参照することにして、手間を省きました。
private byte[] Compile(string sourceCodeString)
{
// ソースコードの準備
var sourceCode = SourceText.From(sourceCodeString);
var sourceCodeOptions = CSharpParseOptions.Default.WithLanguageVersion(LanguageVersion.CSharp10);
var parsedSyntaxTree = SyntaxFactory.ParseSyntaxTree(sourceCode, sourceCodeOptions);
// コード内で参照するDLLの準備
var dllDirectoryPath = Path.GetDirectoryName(typeof(object).Assembly.Location)!;
var references = new List<string>()
{
"netstandard",
"System",
"System.Runtime",
"System.Private.CoreLib",
"System.Console"
}
.Select(x => $"{Path.Combine(dllDirectoryPath, x)}.dll")
.Select(x => MetadataReference.CreateFromFile(x))
.ToArray();
// コンパイル設定
var options = new CSharpCompilationOptions(
OutputKind.DynamicallyLinkedLibrary,
optimizationLevel: OptimizationLevel.Release,
assemblyIdentityComparer: DesktopAssemblyIdentityComparer.Default);
// コンパイル
var compilation = CSharpCompilation.Create("test",
new[] { parsedSyntaxTree },
references: references,
options: options);
// 発行処理
using var stream = new MemoryStream();
var compileResult = compilation.Emit(stream);
if (!compileResult.Success)
throw new Exception(string.Join("\n", compileResult.Diagnostics));
stream.Seek(0, SeekOrigin.Begin);
var compiledAssembly = stream.ToArray();
return compiledAssembly;
}
コンパイルしたバイナリデータをインスタンス化する
引数にコンパイルしたバイナリデータを受け取り、objectのインスタンスを返却する関数です。
同時にAssemblyLoadContextを返しているのは、使用後にメモリからアンロードするためです。
ちなみに今回は”Program”というクラスを対象としてハードコーディングしています。
private (object, AssemblyLoadContext) CreateInstance(byte[] compiledAssembly, object[]? args = null)
{
using var stream = new MemoryStream(compiledAssembly);
var context = new AssemblyLoadContext(null, true);
Assembly assembly = context.LoadFromStream(stream);
var type = assembly.GetTypes().FirstOrDefault(x => x.Name == "Program");
if (type == null)
throw new Exception();
var instance = Activator.CreateInstance(type, args);
if (instance == null)
throw new Exception();
return (instance, context);
}
呼び出し元のAPIを作成する
今回はCompile/ExecuteというAPIで実行できるようにControllerを構成しました。
以下の順に処理するAPIになっています。
1. RequestBodyからソースコードを読み取る
2. コンパイル
3. インタンス化
4. Main関数の実行
5. アンロード
6. コンソール出力内容の返却
[ApiController]
[Route("[controller]/[action]")]
public class CompileController : Controller
{
[HttpPost]
public async Task<string> Execute()
{
var reader = new StreamReader(Request.Body);
var sourceCodeString = await reader.ReadToEndAsync();
var compiledAssembly = Compile(sourceCodeString);
var (program, context) = CreateInstance(compiledAssembly);
var type = program.GetType();
var methodInfo = type.GetMethod("Main")!;
using var stringWriter = new StringWriter();
Console.SetOut(stringWriter);
try
{
methodInfo.Invoke(program, null);
}
finally
{
context.Unload();
}
return stringWriter.ToString();
}
}
実行
Visual Studioをデバッグ起動して、サーバーを立ち上げたら、PostmanでAPIをコールしてみます。
今回はProgramクラスのMain関数を実行するようにAPIを構築したので、今回APIに送るソースコードは以下のようにしました。
using System;
public class Program
{
public void Main()
{
Console.WriteLine("Hello World");
}
}
PostmanのBodyにrowデータとして書き込んで実行しました。
レスポンスのBodyに「Hello World」が入っているのが確認できたので完了です。
終わりに
今回はコードを実行することで完了としましたが、dllやexeを作成することもできます。
実際にサービスレベルにするのであれば、エディタ側の方が大変ですし、DLLも事前にロードしておいて実行速度を上げるなど工夫が必用かなと思います。
全体のソースコードはgithubに置いておきます。