Skip to content

C# スクリプト・Python は使えるか ​

第5版作成 最終更新 (日本時間)
確認バージョン1.5.1.01.5.8.1

標準のプリザンターでは、サーバースクリプトに使える言語は JavaScript だけです。 サーバースクリプトは ClearScript(V8 エンジン)で実行されています。

このページでは次の 2 つを紹介します。いずれも本体コードの改変が必要で、標準機能ではありません。

  • C# スクリプト: 本体を改変し、model の取得・設定が動くところまで試作した例
  • Python: IronPython で実現できるかの調査と、サンドボックスの設計(Python の調査は 1.5.1.0 が対象)

詳しい設計は改修・設計メモの サーバースクリプトを Python で書けるようにする、C# スクリプト(Roslyn)をサーバースクリプトに使う、TypeScript・Lua などを含めた サーバースクリプトに使える言語の比較 にあります。

本体改変について

試すには、Visual Studio や Visual Studio Code など、ビルドして実行する環境が必要です。ビルド環境は 公式リポジトリの CONTRIBUTING.md を参照してください。

現在の仕組み:ClearScript(V8) ​

ClearScript は、Microsoft が開発している .NET 向けのスクリプトエンジンホスティングライブラリです。Google の V8 エンジンを .NET アプリケーションに組み込めます。プリザンターは NuGet パッケージ Microsoft.ClearScript.Complete を参照しています。

xml
<PackageReference Include="Microsoft.ClearScript.Complete" Version="7.5.0" />

バージョンは調査時点(1.5.1.0)のものです。1.5.8.1 のソースでは 7.5.1 を参照しています(Implem.Pleasanter.csproj)。ScriptEngine のコンストラクタは 1.5.8.1 でも次のとおりです(ScriptEngine.cs)。

ScriptEngine クラスは V8ScriptEngine を薄くラップしたものです。

csharp
public ScriptEngine(bool debug)
{
    var flags = V8ScriptEngineFlags.EnableDateTimeConversion;
    if (debug)
    {
        flags |= V8ScriptEngineFlags.EnableDebugging
            | V8ScriptEngineFlags.EnableRemoteDebugging;
    }
    v8ScriptEngine = new V8ScriptEngine(flags);
}

実行時には AddHostObject で C# のオブジェクト(context、model、items、columns など)を JavaScript 側に公開します。JavaScript から見えるのは、この AddHostObject で渡されたオブジェクトと、AddHostType で渡された型(1.5.8.1 では Newtonsoft.Json.JsonConvert)だけです(ServerScriptUtilities.cs)。

ClearScript(V8)が安全な理由 ​

V8 はもともとブラウザ用のエンジンで、ファイル操作・プロセス起動・ネットワーク接続・環境変数アクセス・OS シャットダウンといった OS 操作の API をエンジン自体が持っていません(Node.js の fs や child_process は Node.js が独自に追加したものです)。そのため、AddHostObject で渡したもの以外には原理的にアクセスできません。サンドボックスが設計上そのまま手に入る、というのが ClearScript(V8)が採用されている大きな理由の一つです。

ClearScript に依存している箇所 ​

1.5.8.1 で Microsoft.ClearScript を参照しているのは、ScriptEngine.cs のほか、ServerScriptFile.cs・ServerScriptCsv.cs($ps.file と CSV。エラー通知に ScriptObject を受け取り、戻り値を V8ScriptEngine.Current.Script.Array.from() で JavaScript の配列にしている)、FormulaServerScriptUtilities.cs と Filters/HandleErrorExAttribute.cs(ScriptEngineException の判定)です(ServerScriptFile.cs#L25、ServerScriptCsv.cs#L59-L61)。また $ps などを組み立てる ServerScriptJsLibraries.cs は JavaScript のコードそのものです。別の言語のエンジンを足すときは、これらが改修対象になります。

EnableDateTimeConversion による日付の変換については 日付の扱い を参照してください。

C# スクリプトを使えるようにする(試作) ​

Microsoft.CodeAnalysis.CSharp.Scripting を使い、パラメータで実行エンジンを切り替えられるようにする試作です。model や context などの基本部分のみの実装で、$ps などの拡張関数は実装していません。

想定する書き方 ​

csharp
// 基本的な取得方法
var title = model.Get("Title");
var status = model.Get("Status");
//インデクサ経由での取得
var body = model["Body"];

// 基本的な設定方法
model.Set("Title", "新しいタイトル");
model.Set("Status", 200);
// インデクサーを使用
model["Title"] = "更新されたタイトル";
model["Status"] = 900;

System 名前空間に加え、LINQ を使えるよう System.Linq と System.Collections.Generic も使えるようにします。

実装の手順 ​

  1. NuGet で Microsoft.CodeAnalysis.CSharp.Scripting を追加します(依存するコード解析系パッケージもそのまま追加します)。
  2. パラメータを追加します。Implem.ParameterAccessor\Parts\Script.cs に列挙体 ServerScriptEngineTypes(ClearScript / CSharpScript)とプロパティ ServerScriptEngineType(既定 ClearScript)を追加し、Script.json に "ServerScriptEngineType": "CSharpScript" を書きます。
  3. 抽象化レイヤー(IScriptEngine)を追加し、既存の ClearScript 実装を壊さずに切り替えられるようにします。
  4. ClearScript 用のラッパー(ClearScriptEngineWrapper) と C# スクリプト用のラッパー(CSharpScriptEngineWrapper) を作ります。
  5. ファクトリ(ScriptEngineFactory) でパラメータに応じてエンジンを選び、既存の ScriptEngine クラスはファクトリで作ったエンジンに処理を委譲する形に書き換えます。
  6. ServerScriptUtilities.cs で new ScriptEngine(debug: debug, engineType: Parameters.Script.ServerScriptEngineType) のようにエンジン種別を渡します。
  7. ClearScript 用の組み込み関数群(ServerScriptJsLibraries.ScriptInit()、model.File.Script()、model.Csv.Script()、ServerScriptJsLibraries.Scripts())を読み込む部分に分岐を入れ、C# スクリプトのときは読み込まないようにします(本番運用するなら、これらを C# に書き換えたものが別途必要です)。

インターフェースは次のとおりです。

csharp
using System;

namespace Implem.Pleasanter.Libraries.ServerScripts
{
    public interface IScriptEngine : IDisposable
    {
        void SetContinuationCallback(Func<bool> callback);
        void AddHostType(Type type);
        void AddHostObject(string itemName, object target);
        void Execute(string code, bool debug);
        object Evaluate(string code);
    }
}

CSharpScriptEngineWrapper のポイントは次の 3 点です。コード全文は下の「CSharpScriptEngineWrapper.cs」を開いてください。

  • CreateScriptOptions で参照(System、System.Linq、System.Collections.Generic、System.Dynamic など)とインポートを追加する
  • CreateGlobals で、AddHostObject で渡されたオブジェクトをグローバル変数(ScriptGlobals)として公開する。model / saved / columns は ExpandoObject を扱いやすくする ExpandoObjectWrapper(Get / Set / インデクサ / GetString / GetInt などを持つ)でラップする
  • C# スクリプトの実行(CSharpScript.RunAsync / ContinueWithAsync)は非同期なので、既存インターフェースに合わせて同期メソッドから呼び出す
CSharpScriptEngineWrapper.cs
csharp
using Microsoft.CodeAnalysis.CSharp.Scripting;
using Microsoft.CodeAnalysis.Scripting;
using System;
using System.Collections.Generic;
using System.Dynamic;
using System.Linq;
using System.Reflection;
using System.Threading.Tasks;

namespace Implem.Pleasanter.Libraries.ServerScripts
{
    public class CSharpScriptEngineWrapper : IScriptEngine
    {
        private readonly Dictionary<string, object> HostObjects = new Dictionary<string, object>();
        private readonly HashSet<Type> HostTypes = new HashSet<Type>();
        private ScriptState<object> ScriptState;
        private Func<bool> ContinuationCallback;

        public void SetContinuationCallback(Func<bool> callback)
        {
            ContinuationCallback = callback;
        }

        public CSharpScriptEngineWrapper(bool debug)
        {
            //デバッグまで考えるのは今は見送り
        }

        public void AddHostType(Type type)
        {
            if (type != null)
            {
                HostTypes.Add(type);
            }
        }

        public void AddHostObject(string itemName, object target)
        {
            if (!string.IsNullOrEmpty(itemName) && target != null)
            {
                // ExpandoObject の場合はラッパーでラップして使いやすくする
                if (target is ExpandoObject expando)
                {
                    HostObjects[itemName] = new ExpandoObjectWrapper(expando);
                }
                else
                {
                    HostObjects[itemName] = target;
                }
            }
        }

        public void Execute(string code, bool debug)
            => ExecuteAsync(code).GetAwaiter().GetResult();

        public object Evaluate(string code)
            => EvaluateAsync(code).GetAwaiter().GetResult();

        private async Task ExecuteAsync(string code)
        {
            var globals = CreateGlobals();
            var options = CreateScriptOptions();

            if (ScriptState == null)
            {
                ScriptState = await CSharpScript.RunAsync(
                    code,
                    options,
                    globals,
                    globalsType: typeof(ScriptGlobals));
            }
            else
            {
                ScriptState = await ScriptState.ContinueWithAsync(
                    code,
                    options);
            }
        }

        private async Task<object> EvaluateAsync(string code)
        {
            var globals = CreateGlobals();
            var options = CreateScriptOptions();

            if (ScriptState == null)
            {
                ScriptState = await CSharpScript.RunAsync(
                    code,
                    options,
                    globals,
                    globalsType: typeof(ScriptGlobals));
            }
            else
            {
                ScriptState = await ScriptState.ContinueWithAsync(
                    code,
                    options);
            }

            return ScriptState.ReturnValue;
        }

        private ScriptOptions CreateScriptOptions()
        {
            var options = ScriptOptions.Default
                .WithReferences(typeof(object).Assembly)
                .WithReferences(typeof(System.Linq.Enumerable).Assembly)
                .WithReferences(typeof(System.Collections.Generic.List<>).Assembly)
                .WithReferences(typeof(System.Dynamic.ExpandoObject).Assembly)
                .WithReferences(typeof(ExpandoObjectWrapper).Assembly)
                .WithImports("System")
                .WithImports("System.Linq")
                .WithImports("System.Collections.Generic")
                .WithImports("System.Dynamic")
                .WithImports("Implem.Pleasanter.Libraries.ServerScripts");

            foreach (var type in HostTypes)
            {
                options = options.WithReferences(type.Assembly);
                if (!string.IsNullOrEmpty(type.Namespace))
                {
                    options = options.WithImports(type.Namespace);
                }
            }

            return options;
        }

        private ScriptGlobals CreateGlobals()
        {
            var globals = new ScriptGlobals();
            
            foreach (var obj in HostObjects)
            {
                globals.SetProperty(obj.Key, obj.Value);
            }

            return globals;
        }

        public void Dispose()
        {
            HostObjects?.Clear();
            HostTypes?.Clear();
            ScriptState = null;
        }

        /// <summary>
        /// C# Script用のグローバル変数を保持する静的クラス
        /// </summary>
        public class ScriptGlobals
        {
            private readonly Dictionary<string, object> Properties = new Dictionary<string, object>();

            public void SetProperty(string name, object value)
            {
                Properties[name] = value;
            }

            public object GetProperty(string name)
            {
                return Properties.TryGetValue(name, out var value) ? value : null;
            }

            // ExpandoObject系のプロパティは型を明示
            public ExpandoObjectWrapper model => GetProperty("model") as ExpandoObjectWrapper;
            public ExpandoObjectWrapper saved => GetProperty("saved") as ExpandoObjectWrapper;
            public ExpandoObjectWrapper columns => GetProperty("columns") as ExpandoObjectWrapper;

            // その他のプロパティはobject型のまま
            public object context => GetProperty("context");
            public object grid => GetProperty("grid");
            public object depts => GetProperty("depts");
            public object groups => GetProperty("groups");
            public object users => GetProperty("users");
            public object siteSettings => GetProperty("siteSettings");
            public object view => GetProperty("view");
            public object items => GetProperty("items");
            public object hidden => GetProperty("hidden");
            public object responses => GetProperty("responses");
            public object elements => GetProperty("elements");
            public object extendedSql => GetProperty("extendedSql");
            public object notifications => GetProperty("notifications");
            public object httpClient => GetProperty("httpClient");
            public object utilities => GetProperty("utilities");
            public object logs => GetProperty("logs");
            public object _file_cs => GetProperty("_file_cs");
            public object _csv_cs => GetProperty("_csv_cs");
        }

        /// <summary>
        /// ExpandoObject をC# Script から扱いやすくするラッパークラス
        /// </summary>
        public class ExpandoObjectWrapper
        {
            private readonly ExpandoObject InnerObject;
            private readonly IDictionary<string, object> Dictionary;

            public ExpandoObjectWrapper(ExpandoObject obj)
            {
                InnerObject = obj;
                Dictionary = (IDictionary<string, object>)obj;
            }

            // プロパティ値を取得(型安全版)
            public T Get<T>(string propertyName)
            {
                if (Dictionary.TryGetValue(propertyName, out var value))
                {
                    if (value == null) return default(T);
                    if (value is T typedValue) return typedValue;
                    try
                    {
                        return (T)Convert.ChangeType(value, typeof(T));
                    }
                    catch
                    {
                        return default(T);
                    }
                }
                return default(T);
            }

            // プロパティ値を取得(object版)
            public object Get(string propertyName)
            {
                return Dictionary.TryGetValue(propertyName, out var value) ? value : null;
            }

            // プロパティ値を設定
            public void Set(string propertyName, object value)
            {
                Dictionary[propertyName] = value;
            }

            // プロパティの存在確認
            public bool Has(string propertyName)
            {
                return Dictionary.ContainsKey(propertyName);
            }

            // インデクサーでのアクセス
            public object this[string propertyName]
            {
                get => Get(propertyName);
                set => Set(propertyName, value);
            }

            // 便利メソッド
            public string GetString(string propertyName) => Get(propertyName)?.ToString() ?? string.Empty;
            public int GetInt(string propertyName) => Get<int>(propertyName);
            public decimal GetDecimal(string propertyName) => Get<decimal>(propertyName);
            public bool GetBool(string propertyName) => Get<bool>(propertyName);
            public DateTime GetDateTime(string propertyName) => Get<DateTime>(propertyName);

            // すべてのキーを取得
            public IEnumerable<string> Keys() => Dictionary.Keys;

            // 元の ExpandoObject を取得
            public ExpandoObject GetInnerObject() => InnerObject;
        }
    }
}

動作確認 ​

条件「画面表示の前」に次のスクリプトを書き、タイトルに現在日時がセットされることを確認しています。

csharp
model.Set("Title", System.DateTime.Now);

本番環境で使うには、ClearScript 用に用意されている組み込み関数群の移植などがまだ必要です。

また、C# スクリプトはホストと同じ .NET ランタイムで動くため、System.IO.File などを完全修飾名やリフレクションで呼べてしまい、V8 のようなサンドボックスは作れません。一般のユーザーがスクリプトを書く環境に出してよいかの評価は C# スクリプト(Roslyn)をサーバースクリプトに使う を参照してください。

Python を使えるか(IronPython の調査) ​

候補ライブラリ ​

項目IronPython 3Python.NET (pythonnet)
方式.NET 純粋実装の PythonCPython を .NET に埋め込み
NuGet パッケージIronPython (3.4.x)pythonnet (3.0.x)
Python 互換性Python 3.4 相当CPython 完全互換
外部依存なし(NuGet のみ)CPython のインストールが必須
.NET 連携DLR ベースで直接連携型変換が必要
NumPy / Pandas利用不可利用可能
サンドボックス4 層ロックダウンで実現可能制限が困難
スレッドセーフスコープ分離で安全GIL による制約あり
Docker 対応追加インストール不要Python ランタイム必要
ライセンスApache 2.0MIT

サーバースクリプトに使うなら IronPython 3 が適している、というのが調査の結論です。

  • NuGet パッケージの追加だけで完結し、CPython のインストールや Docker イメージの変更が要りません。
  • DLR 上で動作し、scope.SetVariable() で ClearScript の AddHostObject と同じように C# オブジェクトを公開できます。ExpandoObject を Python から直接操作できるため、既存のホストオブジェクトを流用できます。
  • サーバースクリプトの用途(値操作、条件分岐、API 呼び出し)に NumPy などの科学計算ライブラリは不要です。
python
# サーバスクリプト(Python版のイメージ)
if context.Action == 'create':
    model.ClassA = f"作成者: {context.UserName}"
    model.NumA = model.NumB * 1.1

import json
data = json.loads(model.DescriptionA)
model.ClassB = data.get('category', '未分類')

import datetime
today = datetime.date.today()
model.DateA = today.isoformat()

課題:そのままでは OS に触れてしまう ​

IronPython は .NET 上で動くため、何も制限しなければ .NET の全機能にアクセスできてしまいます(import clr から System.Diagnostics.Process でコマンド実行、System.IO.File でファイル読み取り、os.system など)。

図を読み込み中…

V8 が「API が存在しないから安全(原理的保証)」なのに対し、IronPython は「API はあるが封鎖して安全にする(実装的保証)」という逆のアプローチになります。

4 層防御のサンドボックス ​

1 つの仕組みに頼らず、4 つの防御レイヤーを重ねます(多層防御)。

図を読み込み中…

レイヤー防御対象方法
Layer 1危険な組み込み関数__builtins__ をホワイトリストの辞書に差し替える(open、exec、eval、compile、__import__、exit / quit、globals / locals、breakpoint、input などを除去)
Layer 2危険なモジュールカスタム __import__ で math、json、datetime、re、decimal、string、collections、itertools、functools、copy などだけを許可
Layer 3外部 .py ファイルengine.SetSearchPaths(new List<string>()) で検索パスを空にする
Layer 4.NET アセンブリclr、System、Microsoft、os、subprocess などを sys.modules[mod] = None で封鎖(モジュールポイズニング)

組み合わせる理由は次のとおりです。

  • SearchPaths を空にしても、os や sys のようなビルトインモジュール(C# 実装)はファイルシステムを経由せず読み込まれるため防げません。Layer 2 のカスタム __import__ と組み合わせます。
  • sys.modules の操作だけでは、del sys.modules['os'] で消してから再インポートする回避が可能です。sys 自体へのアクセスも Layer 2 で制限する必要があります。
  • .NET Framework 時代の Code Access Security(CAS)は .NET Core 以降で廃止されています。プリザンターは .NET 10 をターゲットにしているため、サンドボックスはすべてアプリケーションレベルで実装します。

適用後にスクリプトから見えるのは、ホストオブジェクト(model、context、items、columns など)、副作用のない組み込み関数(len、str、int、list、range、sorted など)、純粋演算の標準モジュール(math、json、datetime、re など)だけになります。

テスト期待結果
import os / import subprocess / import clr / import SystemImportError
open('/etc/passwd') / exec('import os') / eval('1+1') / exit()NameError
import math; math.sqrt(16)4.0
import json; json.dumps({"a": 1})'{"a": 1}'
model.ClassA = 'test'正常に設定される
items.Get(123)正常に API 呼び出し

タイムアウト制御 ​

ClearScript では ContinuationCallback で実行中のタイムアウトを確認しています。IronPython では sys.settrace のコールバックで期限を確認し、超えたら TimeoutError を投げることで同じことを実現します。ステートメントごとに呼ばれるため、無限ループの検出にも有効です。

注意が必要なエスケープ手法 ​

リスク説明対策
object.__subclasses__()全サブクラスから危険な型を探索type の除外 + __subclasses__ アクセス制限
__class__.__mro__メソッド解決順序を辿ってエスケープメタクラス属性のブロック
ホストオブジェクトの .GetType()C# オブジェクトからリフレクションSafeHostObject ラッパーの検討
CPU 枯渇(while True: pass)無限ループsys.settrace + タイムアウト
メモリ枯渇([0] * 10**9)大量メモリ割り当てDocker のメモリ制限

実装する場合の構成 ​

  • IScriptEngine インターフェースを抽出し、既存の V8 ラッパーと PythonScriptEngine を ScriptEngineFactory で切り替えます。

図を読み込み中…

  • スクリプトごとに言語を選べるようにし、サイト単位の既定言語も設定できるようにします。Language が未指定(null)なら JavaScript として動くので、既存のスクリプトとの後方互換性は保たれます。

図を読み込み中…

  • 導入は段階的に進めます。
Phase内容
Phase 1IScriptEngine インターフェースの抽出、既存コードのリファクタリング
Phase 2PythonScriptEngine の実装(IronPython 統合 + サンドボックス)
Phase 3UI 対応(言語選択ドロップダウン、コードエディタの切り替え)
Phase 4テスト(既存 JS の回帰 + Python + サンドボックス突破テスト)

調査の評価としては実現可能性が高いものの、最大の課題はサンドボックスです。V8 のような原理的保証ではないため、テストの徹底と継続的な脆弱性検証が欠かせません。言語の切り替えのデータ構造、同じ条件に 2 つの言語があるときの実行順、封鎖するモジュールの一覧などは サーバースクリプトを Python で書けるようにする にまとめています。

関連ページ ​

変更履歴

第5版バックグラウンドサーバースクリプトの仕組みと、サーバースクリプトの他言語対応・DLL 実行・cron スケジュールの改修・設計メモを追加
第4版「サーバースクリプト」を 1.5.8.1 のソースで検証して修正
第3版元記事への言及を整理し、必要なコードをページに収録。検索機能に一覧の検索と絞り込みを追加
第2版記事のファイル名に並び順の番号を付け、元記事リンクを frontmatter の sources に移行
第1版「サーバースクリプト」セクションの記事を追加