跳至主要内容

GST.Rpa.Scripting — C# Script 引擎

項目內容
適用對象GST 內部開發人員
前置知識C# / .NET 8 / Roslyn 基礎概念
閱讀時間20 分鐘
最後更新2026-04-04
版本1.0.0

1. 模組職責

GST.Rpa.Scripting 提供 Roslyn-based 的 C# 腳本即時編譯與執行引擎。其核心職責:

  • Hot Compilation — 透過 Microsoft.CodeAnalysis.CSharp.Scripting 在執行期動態編譯 C# 程式碼
  • RPA API Injection — 將 RpaApp(UI 自動化)、RpaContext(工作流程上下文)等物件注入腳本作為頂層變數
  • Sandbox 安全管控 — 限制腳本可用的 namespace 與 API,防止存取檔案系統、網路、Process 等敏感資源
  • SHA256 快取 — 以原始碼雜湊值為 key 快取已編譯的 Script 物件,避免重複編譯
  • Breakpoint 除錯 — 支援設定中斷點、單步執行、繼續執行等基本除錯功能

DI 註冊

透過 ServiceCollectionExtensions.AddRpaScripting() 一次註冊所有服務:

public static IServiceCollection AddRpaScripting(this IServiceCollection services)
{
services.AddSingleton<ScriptSandbox>();
services.AddSingleton<ScriptCompilationCache>();
services.AddSingleton<IRpaScriptHost, RpaScriptHost>();
return services;
}

也支援自訂 Sandbox 設定的多載:

public static IServiceCollection AddRpaScripting(
this IServiceCollection services, Action<ScriptSandbox> configureSandbox)

2. NuGet 引用與相依

套件用途
Microsoft.CodeAnalysis.CSharp.ScriptingRoslyn C# 腳本 API(編譯、執行)
Microsoft.Extensions.DependencyInjectionDI 容器擴充
Microsoft.Extensions.Logging.Abstractions結構化日誌介面

專案引用

專案用途
GST.Rpa.CoreRpaContextGstRpaException 等核心模型
GST.Rpa.AutomationRpaApp UI 自動化 API

3. 核心 API

3.1 IRpaScriptHost

腳本引擎的主要介面,涵蓋編譯、執行與除錯功能:

public interface IRpaScriptHost
{
// 編譯與執行
Task<ScriptCompilationResult> CompileAsync(string code, CancellationToken cancellationToken = default);
Task<ScriptExecutionResult> ExecuteAsync(string code, RpaScriptGlobals globals, CancellationToken cancellationToken = default);
Task<ScriptExecutionResult> ExecuteFromFileAsync(string filePath, RpaScriptGlobals globals, CancellationToken cancellationToken = default);
void ClearCache();

// Breakpoint 管理
Task SetBreakpointAsync(int lineNumber);
Task RemoveBreakpointAsync(int lineNumber);
Task StepOverAsync();
Task ContinueAsync();
IReadOnlySet<int> Breakpoints { get; }
event EventHandler<BreakpointHitEventArgs>? BreakpointHit;
}
方法說明
CompileAsync編譯腳本並回傳診斷結果,不執行。已快取的腳本直接回傳成功
ExecuteAsync編譯(或從快取取得)後執行腳本,注入 RpaScriptGlobals 作為全域變數
ExecuteFromFileAsync從檔案載入腳本原始碼後呼叫 ExecuteAsync
ClearCache清除所有編譯快取

3.2 RpaScriptHost(實作)

RpaScriptHost 以 primary constructor 注入依賴:

public class RpaScriptHost(
ScriptCompilationCache cache,
ScriptSandbox sandbox,
ILogger<RpaScriptHost>? logger = null) : IRpaScriptHost

編譯流程 (CompileAsync):

  1. 驗證原始碼非空白
  2. 呼叫 sandbox.ValidateSource(code) 檢查禁用 pattern
  3. 計算 SHA256 hash,若快取命中則直接回傳成功
  4. 建立 ScriptOptions(參照組件 + 預設 imports)
  5. 呼叫 CSharpScript.Create<object>()Compile()
  6. DiagnosticSeverity.Error 轉換為 ScriptError 清單
  7. 編譯成功則存入快取

預設引入的組件與 namespace

ScriptOptions.Default
.WithReferences(
typeof(object).Assembly, // System.Runtime
typeof(Enumerable).Assembly, // System.Linq
typeof(RpaApp).Assembly, // GST.Rpa.Automation
typeof(RpaContext).Assembly) // GST.Rpa.Core
.WithImports(
"System",
"System.Linq",
"System.Collections.Generic",
"System.Threading.Tasks");

執行流程 (ExecuteAsync):

  1. 呼叫 CompileAsync 取得編譯結果(失敗則拋出 ScriptCompilationException
  2. 從快取取得已編譯的 Script<object>
  3. 建立 linked CancellationTokenSource,套用 sandbox.ExecutionTimeout
  4. 若有 breakpoint 設定,觸發 BreakpointHit 事件並暫停等待
  5. 呼叫 script.RunAsync(globals, token) 執行
  6. 回傳 ScriptExecutionResult(包含 ReturnValueElapsed
  7. Timeout 時回傳 TimeoutException,其他例外回傳在 Error 欄位

3.3 RpaScriptGlobals

注入腳本的全域物件。腳本可直接以頂層識別碼存取:

public class RpaScriptGlobals
{
public RpaScriptGlobals(RpaApp app, RpaContext context, Action<string>? logAction = null);

// 屬性
public RpaApp App { get; } // UI 自動化 API
public RpaContext Context { get; } // 工作流程上下文(變數共享)
public CancellationToken CancellationToken { get; internal set; } // 由 Host 設定,Timeout 用

// Helper 方法
public void Wait(int milliseconds); // 暫停(取消感知)
public void Assert(bool condition, string? message = null); // 條件斷言,失敗拋例外
public void Log(string message); // 寫入執行日誌
public void Screenshot(string name); // 截圖並存入 Context
}

腳本中的使用方式

// 在腳本中,這些都是頂層變數
var window = App.FindWindow("Notepad");
Context.Variables["result"] = "OK";
Wait(1000);
Assert(window != null, "找不到 Notepad 視窗");
Log("操作完成");
Screenshot("step1");

3.4 ScriptSandbox

以文字 pattern matching 驗證腳本原始碼,阻擋危險 API 呼叫:

public class ScriptSandbox
{
public TimeSpan ExecutionTimeout { get; set; } = TimeSpan.FromSeconds(30);
public long MemoryLimitBytes { get; set; } = 256 * 1024 * 1024; // 256 MB(尚未強制執行)
public IReadOnlyList<string> DeniedPatterns { get; set; }
public void ValidateSource(string code);
}

預設禁用 Pattern

Pattern阻擋目的
System.IO.File / FileInfo / StreamWriter / StreamReader / FileStream / Directory / Path檔案系統存取
System.Net網路存取
System.Diagnostics.Process執行外部程式
System.Reflection.Assembly動態載入組件
System.Runtime.InteropServicesP/Invoke 呼叫
System.Environment.Exit終止程式

ValidateSource() 逐行掃描原始碼,若發現匹配的 pattern 會拋出 ScriptCompilationException,附帶行號與欄位資訊。

已知限制

V1 採用文字 pattern matching,可透過 using alias、字串拼接或簡短型別名稱繞過。未來可考慮改用 Roslyn SemanticModel 做編譯後語意分析以強化安全性。

3.5 完整執行時序

下圖把 ExecuteAsync 的完整路徑(驗證 → 編譯 / 取快取 → 執行)拆解成可觀察的訊息,含三條主要錯誤分支:Sandbox 拒絕、編譯失敗、Timeout / 執行例外:

時序圖對應的關鍵失敗模式:

  • Sandbox 拒絕:原始碼匹配 DeniedPatterns,連編譯都不執行——回傳的 ScriptCompilationException.Errors 會包含被擋的行號 / 欄位
  • 編譯失敗:Roslyn 回報 DiagnosticSeverity.Error——常見於型別找不到、語法錯誤
  • Timeout:超過 sandbox.ExecutionTimeout,linked CTS 觸發取消——Error 欄位是 TimeoutException
  • 執行例外:腳本內部 throw / Assert(false)、UI 操作失敗等——Error 欄位保留原始 Exception

3.6 ScriptCompilationCache

ConcurrentDictionary<string, object> 儲存已編譯的 Script 物件,key 為原始碼的 SHA256 hash:

public class ScriptCompilationCache
{
public static string ComputeHash(string code); // SHA256 → hex string
public bool TryGet(string hash, out object? script);
public void Set(string hash, object script);
public void Clear();
public int Count { get; }
}

設計要點

  • 使用 ConcurrentDictionary 確保執行緒安全
  • 快取 value 型別為 object 而非 Script<object>,避免 Metalama compile-time 型別衝突
  • Hash 計算使用 SHA256.HashData() + Convert.ToHexString(),產生 64 字元 hex 字串
  • TryAdd 語意保證同一 hash 不會被覆寫

4. 回傳模型

4.1 ScriptCompilationResult

public record ScriptCompilationResult(bool IsSuccessful, IReadOnlyList<ScriptError> Errors);
屬性型別說明
IsSuccessfulbool編譯是否成功
ErrorsIReadOnlyList<ScriptError>編譯錯誤清單(成功時為空)

4.2 ScriptExecutionResult

public record ScriptExecutionResult(bool IsSuccessful, object? ReturnValue, TimeSpan Elapsed, Exception? Error);
屬性型別說明
IsSuccessfulbool執行是否成功
ReturnValueobject?腳本回傳值
ElapsedTimeSpan執行耗時(含編譯時間)
ErrorException?失敗時的例外(Timeout 為 TimeoutException

4.3 ScriptError

public record ScriptError(int Line, int Column, string Message);
屬性型別說明
Lineint錯誤所在行號(1-based)
Columnint錯誤所在欄位(1-based)
Messagestring錯誤訊息

4.4 ScriptCompilationException

public class ScriptCompilationException : GstRpaException
{
public IReadOnlyList<ScriptError> Errors { get; }

public ScriptCompilationException(string message, IReadOnlyList<ScriptError> errors);
public ScriptCompilationException(string message, IReadOnlyList<ScriptError> errors, Exception innerException);
}

繼承自 GstRpaException(Core 層基底例外),附帶結構化的錯誤位置資訊。


5. Breakpoint 支援

IRpaScriptHost 提供基本的中斷點除錯機制,主要用於 UI 控制台的腳本除錯功能。

API

方法說明
SetBreakpointAsync(int lineNumber)在指定行號設定中斷點
RemoveBreakpointAsync(int lineNumber)移除指定行號的中斷點
StepOverAsync()設定 step mode 並繼續執行
ContinueAsync()取消 step mode 並繼續執行
Breakpoints (IReadOnlySet<int>)目前已設定的中斷點行號

BreakpointHitEventArgs

當執行命中中斷點時觸發 BreakpointHit 事件:

public class BreakpointHitEventArgs : EventArgs
{
public required int LineNumber { get; init; }
public required IReadOnlyDictionary<string, object?> Variables { get; init; }
public required string StatementText { get; init; }
}
屬性說明
LineNumber命中的行號
Variables當前變數快照(來自 RpaContext.Variables
StatementText腳本片段文字(最多 120 字元)

執行流程

  1. 呼叫 SetBreakpointAsync() 設定中斷點
  2. 呼叫 ExecuteAsync() 開始執行
  3. 若有任何 breakpoint 存在,執行前會觸發 BreakpointHit 事件
  4. 執行暫停於 SemaphoreSlim.WaitAsync()
  5. UI 層收到事件後顯示變數快照
  6. 使用者選擇 ContinueAsync()(繼續到下一個斷點)或 StepOverAsync()(單步)
  7. ReleasePause() 釋放 semaphore,執行恢復
備註

目前 V1 的 breakpoint 僅在腳本執行起始處暫停(line 1)。Roslyn scripting API 不提供原生的行級中斷支援,完整的行級除錯需要額外的 source instrumentation 機制。


6. 使用範例

6.1 基本編譯與執行

// 注入或建構
var sandbox = new ScriptSandbox { ExecutionTimeout = TimeSpan.FromSeconds(60) };
var cache = new ScriptCompilationCache();
var host = new RpaScriptHost(cache, sandbox);

// 準備全域物件
var globals = new RpaScriptGlobals(rpaApp, rpaContext, msg => Console.WriteLine(msg));

// 先編譯檢查
var compileResult = await host.CompileAsync(script);
if (!compileResult.IsSuccessful)
{
foreach (var err in compileResult.Errors)
Console.WriteLine($"[{err.Line}:{err.Column}] {err.Message}");
return;
}

// 執行
var result = await host.ExecuteAsync(script, globals);
if (result.IsSuccessful)
Console.WriteLine($"完成,耗時 {result.Elapsed.TotalMilliseconds}ms,回傳: {result.ReturnValue}");
else
Console.WriteLine($"失敗: {result.Error?.Message}");

6.2 自訂 Sandbox

services.AddRpaScripting(sandbox =>
{
sandbox.ExecutionTimeout = TimeSpan.FromMinutes(2);
sandbox.DeniedPatterns = new[]
{
"System.IO.File",
"System.Net",
"System.Diagnostics.Process"
};
});

6.3 Breakpoint 除錯

var host = serviceProvider.GetRequiredService<IRpaScriptHost>();

// 訂閱中斷事件
host.BreakpointHit += (sender, e) =>
{
Console.WriteLine($"中斷於第 {e.LineNumber} 行");
Console.WriteLine($"語句: {e.StatementText}");
foreach (var (name, value) in e.Variables)
Console.WriteLine($" {name} = {value}");
};

// 設定中斷點
await host.SetBreakpointAsync(1);

// 執行(會在 line 1 暫停)
var executeTask = host.ExecuteAsync(code, globals);

// ... 等待使用者操作 ...
await host.ContinueAsync(); // 繼續執行
// 或
await host.StepOverAsync(); // 單步執行

var result = await executeTask;

6.4 從檔案執行

var result = await host.ExecuteFromFileAsync(
@"C:\Scripts\check-window.csx",
globals,
cancellationToken);

7. 架構決策

為何選擇 Roslyn Scripting

考量Roslyn替代方案(Lua / Python)
語言一致性C# — 與主專案相同語言需學習另一語言
型別安全編譯期檢查動態型別
API 注入原生支援 Globals 類別注入需自行實作 binding
生態系完整 .NET BCL 可用有限的互操作
效能首次編譯較慢,快取後極快直譯器啟動快,長期效能較低

MetalamaEnabled=false

專案明確停用 Metalama:

<PropertyGroup>
<MetalamaEnabled>false</MetalamaEnabled>
</PropertyGroup>

原因:Metalama 在編譯期會注入 source generator 與分析器,這些與 Roslyn Scripting API 內部使用的 Microsoft.CodeAnalysis 型別會產生版本衝突。停用 Metalama 讓此專案能乾淨地使用 Roslyn scripting,同時透過 InternalsVisibleTo 保持測試專案存取能力。

SHA256 Hash Cache 設計

  • 為何用 hash 而非原始碼字串做 key:避免大量腳本原始碼在 Dictionary key 中佔用記憶體,SHA256 固定 64 bytes
  • 為何 value 存 objectScript<T>Microsoft.CodeAnalysis 的型別,當 Metalama 啟用時(如在其他專案引用時),compile-time 可能無法解析此型別。用 object 儲存並在取出時強制轉型可繞過此問題
  • ConcurrentDictionary + TryAdd:確保多執行緒環境下同一 hash 只會存入一次編譯結果,避免競爭條件

8. 版本紀錄

版本日期說明
1.02026-04-04初版 — 涵蓋 IRpaScriptHost、Sandbox、Cache、Breakpoint 完整 API 文件