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.Scripting | Roslyn C# 腳本 API(編譯、執行) |
Microsoft.Extensions.DependencyInjection | DI 容器擴充 |
Microsoft.Extensions.Logging.Abstractions | 結構化日誌介面 |
專案引用
| 專案 | 用途 |
|---|---|
GST.Rpa.Core | RpaContext、GstRpaException 等核心模型 |
GST.Rpa.Automation | RpaApp 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):
- 驗證原始碼非空白
- 呼叫
sandbox.ValidateSource(code)檢查禁用 pattern - 計算 SHA256 hash,若快取命中則直接回傳成功
- 建立
ScriptOptions(參照組件 + 預設 imports) - 呼叫
CSharpScript.Create<object>()並Compile() - 將
DiagnosticSeverity.Error轉換為ScriptError清單 - 編譯成功則存入快取
預設引入的組件與 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):
- 呼叫
CompileAsync取得編譯結果(失敗則拋出ScriptCompilationException) - 從快取取得已編譯的
Script<object> - 建立 linked
CancellationTokenSource,套用sandbox.ExecutionTimeout - 若有 breakpoint 設定,觸發
BreakpointHit事件並暫停等待 - 呼叫
script.RunAsync(globals, token)執行 - 回傳
ScriptExecutionResult(包含ReturnValue、Elapsed) - 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.InteropServices | P/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);
| 屬性 | 型別 | 說明 |
|---|---|---|
IsSuccessful | bool | 編譯是否成功 |
Errors | IReadOnlyList<ScriptError> | 編譯錯誤清單(成功時為空) |
4.2 ScriptExecutionResult
public record ScriptExecutionResult(bool IsSuccessful, object? ReturnValue, TimeSpan Elapsed, Exception? Error);
| 屬性 | 型別 | 說明 |
|---|---|---|
IsSuccessful | bool | 執行是否成功 |
ReturnValue | object? | 腳本回傳值 |
Elapsed | TimeSpan | 執行耗時(含編譯時間) |
Error | Exception? | 失敗時的例外(Timeout 為 TimeoutException) |
4.3 ScriptError
public record ScriptError(int Line, int Column, string Message);
| 屬性 | 型別 | 說明 |
|---|---|---|
Line | int | 錯誤所在行號(1-based) |
Column | int | 錯誤所在欄位(1-based) |
Message | string | 錯誤訊息 |
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 字元) |
執行流程
- 呼叫
SetBreakpointAsync()設定中斷點 - 呼叫
ExecuteAsync()開始執行 - 若有任何 breakpoint 存在,執行前會觸發
BreakpointHit事件 - 執行暫停於
SemaphoreSlim.WaitAsync() - UI 層收到事件後顯示變數快照
- 使用者選擇
ContinueAsync()(繼續到下一個斷點)或StepOverAsync()(單步) 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.0 | 2026-04-04 | 初版 — 涵蓋 IRpaScriptHost、Sandbox、Cache、Breakpoint 完整 API 文件 |