跳到主要内容

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.CoreRpaContext、GstRpaException 等核心模型
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(包含 ReturnValue、Elapsed)
  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 存 object:Script<T> 是 Microsoft.CodeAnalysis 的型別,當 Metalama 啟用時(如在其他專案引用時),compile-time 可能無法解析此型別。用 object 儲存並在取出時強制轉型可繞過此問題
  • ConcurrentDictionary + TryAdd:確保多執行緒環境下同一 hash 只會存入一次編譯結果,避免競爭條件

8. 版本紀錄​

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