FURYU Tech Blog - フリュー株式会社

フリュー株式会社の開発者が技術情報を発信するブログです。

C#を組み込み用スクリプト言語として扱う― Roslyn を用いた動的コンパイル基盤の構築 ―

概要

本記事では、C# コンパイラ基盤である Roslyn を利用し、C# を組み込み用スクリプト言語として実行する仕組みを構築した事例を紹介する。

プリントシール機の開発では、機能の変更に応じて処理を頻繁に差し替える必要がある。 このため、再ビルドを伴わずに処理を安全に差し替え可能な仕組みが求められていた。

この課題に対して、「C# スクリプトを Roslyn によって動的コンパイルする」方式を採用した。 これにより、再ビルド不要での機能差し替えと、.NET の豊富な機能を活用した柔軟な機能実装を両立した。

本手法はプロトタイプに留まらず、実際のプリントシール機開発において継続的に運用されており、 機能の差分対応やレタッチ機能の実装において実用的なスクリプト基盤として機能している。

対象者

組み込み用途のスクリプト言語を検討している、またはプリントシール機の内部構造に興味を持つ、Visual Studio と C# の基礎知識を有した開発者を対象としている。

開発環境

  • Windows 11
  • Visual Studio 2022
  • コンソールアプリ
  • .NET 9.0
  • Roslyn(Microsoft.CodeAnalysis.CSharp)5.3.0
  • .NET Framework 4.7.2(既存資産)

背景

プリントシール機開発では、以下のような特性がある。

  • 機種ごとに成果物が異なる
  • レタッチ機能やパラメータの変更頻度が高い
  • 試行錯誤が重要である

このため、処理ロジックを柔軟に差し替える仕組みが必要となる。

課題

従来、Lua および独自スクリプト言語を用いた実装・運用を行っていたが、以下の問題があった。

開発体験
  • Luaおよび独自言語の習得に学習コストを要する
  • ホスト側との橋渡しコードが複雑になる
品質
  • デバッグが難しい
運用・保守
  • 基本機能が限定的であり、実装の多くを独自に補完する必要がある
  • 言語仕様の設計・保守コストが高い
  • 機能追加のたびに言語拡張が必要
  • IDE サポートが乏しい

結果として、開発効率の低下および保守コストの増大が問題となっていた。

要求条件

課題を解決するため、以下の条件で技術選定を行った。

  • 学習コストを増加させない
  • ホスト側との連携を容易にする
  • スクリプト言語としての基本機能および標準ライブラリの充実度を重視する
  • 実行時に処理を差し替え可能とする
  • デバッグ性を確保する

これらの方針を満たす手段として、Roslyn による C# の動的コンパイル方式を採用した。

本システムの実行構成を以下に示す。

flowchart LR
    A["外部スクリプトファイル<br/>Script.cs"] --> B[ソースコード生成]
    B --> C[Roslyn CSharpCompilation]
    C --> D["ILコード生成<br/>(DLL in Memory)"]
    D --> E[AssemblyLoadContext]
    E --> F[アセンブリロード]
    F --> G[EntryPoint 実行]

    subgraph ホストアプリ
        E
        F
        G
    end

    subgraph .NET / 既存資産
        H[標準ライブラリ]
        I["カスタムDLL"]
    end

    C --> H
    C --> I
    G --> H
    G --> I

Roslyn 採用理由

  • C# コンパイラを API として利用できる
  • 実行時にコードをコンパイル可能である
  • エラー情報(行番号など)を取得可能である

さらに重要な点として、.NET が提供する豊富な機能資産をそのまま活用できる点がある。

  • 標準ライブラリ(ファイルIO、LINQ、コレクションなど)をそのまま利用可能
  • 新たなランタイムや言語を導入せずに済む

これにより、

スクリプトでありながら、実質的に .NET アプリケーションと同等の表現力と実行能力を持つという特徴を備える。

他手法との比較

手法 問題
Lua 橋渡しコードが複雑であり、標準ライブラリは最小構成である
Python 橋渡しコードが複雑であり、実行性能が低い場合がある
独自言語 設計・保守コストが高い
DLL事前ビルド 柔軟な差し替えが困難である
C# + Roslyn .NET の機能をそのまま利用可能であり、かつ動的実行が可能である

Roslynのインストール方法

  1. [ツール] → [NuGet パッケージマネージャー] → [ソリューションのNuGet パッケージ管理]
  2. [参照] から「Microsoft.CodeAnalysis.CSharp」を検索し、インストールする

最小実装

基本的な処理の流れは以下の通りである。

  1. スクリプトコード生成
  2. Roslyn によるコンパイル
  3. メモリ上にロード
  4. リフレクションで実行

"Hello Roslyn"を出力するコード

using System.Reflection;
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CSharp;

// 1. スクリプトコード生成
const string sourceCode =
    """
    using System;
    public class Program
    {
        public static void EntryPoint(string[] args)
        {
            Console.WriteLine("Hello Roslyn");
        }
    }
    """;
var syntaxTree = CSharpSyntaxTree.ParseText(sourceCode);

// 2. Roslyn によるコンパイル
// コンパイルオプション
CSharpCompilationOptions compilationOptions = new CSharpCompilationOptions(
    OutputKind.DynamicallyLinkedLibrary
    );

// アセンブリ参照を追加
var runtimeDir = Path.GetDirectoryName(typeof(object).Assembly.Location)!;
var references = new MetadataReference[]
{
    MetadataReference.CreateFromFile(typeof(object).Assembly.Location),
    MetadataReference.CreateFromFile(typeof(Console).Assembly.Location),
    MetadataReference.CreateFromFile(Path.Combine(runtimeDir, "System.Runtime.dll")),
};

var compilation = CSharpCompilation.Create(
    "Script",
    new[] { syntaxTree },
    references,
    compilationOptions);

using var ms = new MemoryStream();
compilation.Emit(ms);

// 3. メモリ上にロード
ms.Seek(0, SeekOrigin.Begin);
var assembly = Assembly.Load(ms.ToArray());

// 4. リフレクションで実行
assembly.GetType("Program")
    .GetMethod("EntryPoint")
    .Invoke(null, new object[] { args });

実行結果

実運用で追加した対応

最小実装により基本動作は確認できたが、実運用では依存関係管理、デバッグ性、長時間稼働時のメモリ管理など、 追加で考慮すべき課題が明らかになった。そこで、各課題に対して以下の対応を行った。

課題 対策
再ビルドなしで処理を差し替えたい 外部スクリプトファイルを読み込む構成とした
既存の .NET 資産を活用したい 参照アセンブリを動的に構成し、必要なランタイムアセンブリを明示的に追加した
独自ライブラリをスクリプトから利用したい カスタム DLL を参照対象に追加した
コンパイルエラーの原因を特定しづらい エラー出力を強化し、診断情報・行番号・列番号を表示するようにした
長時間稼働時のメモリ使用量を抑えたい AssemblyLoadContext を用いて動的ロードしたアセンブリをアンロード可能にした
1. 外部スクリプトの読み込み

スクリプトファイルの差し替えのみで挙動を変更可能にした。

// 外部スクリプトコードの読み込み(コマンドラインでファイル名を指定)
string scriptCode = File.ReadAllText(args[0]);
2. 参照アセンブリの動的構成

既存の .NET Framework 4.7.2の資産と互換性を持たせるため、必要なアセンブリを明示的に追加した。

// アセンブリ参照を追加
var references = new List<MetadataReference>();
var trustedAssemblies = AppContext.GetData("TRUSTED_PLATFORM_ASSEMBLIES")?.ToString();
var assemblyPaths = trustedAssemblies?.Split(Path.PathSeparator).ToHashSet() ?? new HashSet<string>();

// 必要な全てのランタイムアセンブリを追加
var runtimeDir = Path.GetDirectoryName(typeof(object).Assembly.Location)!;

var essentialAssemblies = new[]
{
    Path.Combine(runtimeDir, "System.Private.CoreLib.dll"),
    Path.Combine(runtimeDir, "System.Runtime.dll"),
    Path.Combine(runtimeDir, "System.Console.dll"),
    Path.Combine(runtimeDir, "System.Collections.dll"),
    Path.Combine(runtimeDir, "System.Linq.dll"),
    Path.Combine(runtimeDir, "System.Memory.dll"),
    Path.Combine(runtimeDir, "netstandard.dll"),
    typeof(object).Assembly.Location,
    typeof(System.Collections.Generic.List<>).Assembly.Location,
    typeof(Enumerable).Assembly.Location,
    typeof(Console).Assembly.Location,
    typeof(System.Enum).Assembly.Location,
    typeof(System.ValueType).Assembly.Location,
    typeof(System.Attribute).Assembly.Location,
    Assembly.GetExecutingAssembly().Location
};

// 重複を除いて参照を追加
var addedPaths = new HashSet<string>();
foreach (string assemblyPath in essentialAssemblies)
{
    if (!string.IsNullOrEmpty(assemblyPath) && File.Exists(assemblyPath) && !addedPaths.Contains(assemblyPath))
    {
        references.Add(MetadataReference.CreateFromFile(assemblyPath));
        addedPaths.Add(assemblyPath);
    }
}

// 信頼されたアセンブリから追加で必要なものを検索
if (string.IsNullOrEmpty(trustedAssemblies) == false)
{
    var platformAssemblies = trustedAssemblies.Split(Path.PathSeparator);
    var additionalRequired = new[]
    {
        "mscorlib.dll",           // .NET Framework 4.7.2互換性のため
        "System.dll",
        "System.Core.dll",
        "System.ComponentModel.dll",
        "System.ComponentModel.Primitives.dll"
    };

    foreach (string assemblyName in additionalRequired)
    {
        var assemblyPath = platformAssemblies.FirstOrDefault(x => x.EndsWith(assemblyName));
        if (!string.IsNullOrEmpty(assemblyPath) && File.Exists(assemblyPath) && !addedPaths.Contains(assemblyPath))
        {
            references.Add(MetadataReference.CreateFromFile(assemblyPath));
            addedPaths.Add(assemblyPath);
        }
    }
}
3. カスタム DLL の利用

独自の画像処理ライブラリなどをスクリプトから利用可能とした。

var customDlls = new[] { "FRGraphics.dll" };
foreach (string dll in customDlls)
{
    if (File.Exists(dll))
    {
        references.Add(MetadataReference.CreateFromFile(dll));
    }
}
4. エラー出力の強化
EmitResult result = compilation.Emit(ms);

if (result.Success == false)
{
    Console.WriteLine("コンパイルエラー");
    foreach (var diagnostic in result.Diagnostics)
    {
        if (diagnostic.Severity == DiagnosticSeverity.Error ||
            diagnostic.Severity == DiagnosticSeverity.Warning)
        {
            var location = diagnostic.Location.GetLineSpan();
            Console.WriteLine($"{diagnostic.Severity}: {diagnostic.Id}");
            Console.WriteLine($"  メッセージ: {diagnostic.GetMessage()}");
            Console.WriteLine($"  位置: Line {location.StartLinePosition.Line + 1}, Column {location.StartLinePosition.Character + 1}");
            Console.WriteLine($"  ファイル: {location.Path ?? "生成されたソース"}");
            Console.WriteLine();
        }
    }
    return;
}

コンパイルエラーを詳細に出力し、原因特定を容易にした。

5. アセンブリのアンロード
// カスタムAssemblyLoadContextを作成(アンロードを可能にする)
var loadContext = new AssemblyLoadContext("DynamicContext", isCollectible: true);
・
・
・
// アセンブリをアンロード
 loadContext.Unload();

長時間稼働におけるメモリ管理を改善した。

結果

本手法の導入により、以下の改善が得られた。

  • 開発言語の統一により、スクリプト導入に伴う学習コストの増加を防止した
  • ホスト側との連携コードが簡素化され、実装および保守が容易になった
  • エラー位置の可視化によりデバッグ効率が向上した

特に、言語の統一により開発体験が一貫し、開発効率と保守性の双方に改善が見られた。

注意点・限界

本手法には以下の課題が存在する。

  • Roslynのデメリットとして、コードを実行時にコンパイルする仕組みであるため、その分の処理負荷が発生する点が挙げられる。 ただし、内容が確定した後は、あらかじめDLLとしてビルドしておくことで、この負荷は回避可能である。

まとめ

Roslyn を活用することで、以下を同時に実現できた。

  • 動的な柔軟性
  • 高いパフォーマンス
  • 一貫した開発体験

本手法により、機種差分を考慮した処理を効率的に構築可能となり、開発サイクルの短縮に寄与した。 具体的には、レタッチ機能の実装および調整に要する工数を従来比で約50%削減するとともに、リアルタイムレタッチ機能の実現に貢献した。

組み込み用途においても、C# をスクリプトとして活用するアプローチは、柔軟性と性能を両立する実用的な選択肢である。

著者紹介

大畠 裕

フリュー株式会社 プリントシール機事業部にて、ソフトウエア開発を担当するシニアエキスパート。 長年にわたりプリントシール機の開発に携わり、画像処理を中心に幅広い領域でプロダクトの価値向上に貢献している。

直感的で楽しい操作性の実現や、クリエイティブな表現を引き出す機能開発に注力。 現場での開発に加え、若手エンジニアの育成や技術力向上にも積極的に取り組み、組織全体の成長を支える役割も担っている。 新しい技術の導入や開発プロセスの改善にも意欲的で、ユーザー体験の進化を軸にしたものづくりを推進している。