はじめに
プリザンターの機能の1つであるサーバスクリプト、細かいところを触ろうとおもうととても便利な機能です。
ただし、使用出来る言語はJavaScript(ClearScriptというV8が動くエンジンで実行)だけです。日頃から型に厳格な言語でコーディングしているものからすると、JavaScriptはちょっと苦手・・・。プリザンター本体もC#で出来ているので、せっかくならサーバスクリプトもC#でかけるようにしてみましょう。
今回紹介する方法は本体コードへの改変が必要になります。そのため、実際に本記事に記載されている方法を試すには、Visual StudioとVisual Studio Codeなどのビルドして実行するための環境が必要です。ビルド環境については、公式レポジトリのドキュメントを参照してください。
実装を考える
今回はGetter/Setterやインデクサを経由してのアクセスを考えてみます。
// 基本的な取得方法
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名前空間のものは使えるようにします。せっかくのC#なのでLINQも使いたいので、System.Linq名前空間とSystem.Collections.Generic名前空間も使えるようにしておきます。
実装していく
今回はmodelやtitleなどの基本的な部分のみの実装にとどめます。$psなどの拡張関数の実装まではおこないません。
NuGetでライブラリを追加
Microsoft.CodeAnalysis.CSharp.ScriptingをNuGetで追加します。依存関係でコード解析系のものも追加されますが、そのまま追加してしまってください。
切替のための設定を追加
パラメータで設定を切り替えるようにするために、パラメータに追加します。パラメータを読み出している部分にコードを追加します。
using System.ComponentModel;
namespace Implem.ParameterAccessor.Parts
{
public class Script
{
+ public enum ServerScriptEngineTypes
+ {
+ ClearScript,
+ CSharpScript
+ }
[DefaultValue(true)]
public bool ServerScript { get; set; } = true;
public bool BackgroundServerScript { get; set; }
public bool DisableServerScriptHttpClient { get; set;}
[DefaultValue(10000)]
public long ServerScriptTimeOut { get; set; } = 10000;
public bool ServerScriptTimeOutChangeable { get; set; }
[DefaultValue(0)]
public int ServerScriptTimeOutMin { get; set; } = 0;
[DefaultValue(1000 * 60 * 60 * 24)]
public int ServerScriptTimeOutMax { get; set; } = 1000 * 60 * 60 * 24;
[DefaultValue(100 * 1000)]
public int ServerScriptHttpClientTimeOut { get; set; } = 100 * 1000;
[DefaultValue(0)]
public int ServerScriptHttpClientTimeOutMin { get; set; } = 0;
[DefaultValue(1000 * 60 * 60 * 24)]
public int ServerScriptHttpClientTimeOutMax { get; set; } = 1000 * 60 * 60 * 24;
[DefaultValue(10)]
public int ServerScriptIncludeDepthLimit { get; set; } = 10;
[DefaultValue(true)]
public bool DisableServerScriptFile { get; set; } = true;
[DefaultValue(1)]
public long ServerScriptFileSizeMax { get; set; } = 1;
public string ServerScriptFilePath { get; set; }
+ public ServerScriptEngineTypes ServerScriptEngineType { get; set; } = ServerScriptEngineTypes.ClearScript;
}
}
パラメータファイルに追記
パラメータファイルに先ほど追加したプロパティを追加します。後ほどの検証作業の時にパラメータを差し替えるのが面倒なので、C#スクリプト指定にしてしまいます。
{
"ServerScript": true,
"BackgroundServerScript": false,
"DisableServerScriptHttpClient": false,
"ServerScriptTimeOut": 10000,
"ServerScriptTimeOutChangeable": false,
"ServerScriptTimeOutMin": 0,
"ServerScriptTimeOutMax": 86400000,
"ServerScriptHttpClientTimeOut": 100000,
"ServerScriptHttpClientTimeOutMin": 0,
"ServerScriptHttpClientTimeOutMax": 86400000,
"ServerScriptIncludeDepthLimit": 10,
"DisableServerScriptFile": true,
"ServerScriptFileSizeMax": 1,
- "ServerScriptFilePath": null
+ "ServerScriptFilePath": null,
+ "ServerScriptEngineType": "CSharpScript"
}
抽象化レイヤーの追加
現状のClearScriptの実装を壊さずにC#スクリプトへの対応をおこなうために、抽象化レイヤーを追加していきます。
既存のサーバスクリプトの実装を参考にインターフェースを実装します。既存の実装箇所は
こんな感じになっているので、
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);
}
}
こんな感じでインターフェースを用意してあげます。
ContinuationCallbackの部分、後々の実装の関係でメソッド名を変更しています。
ClearScript用のラッパーを作成
まずは既存のClearScriptの実装を先ほど作成したインターフェースを使う形に変更したものを追加します。
using Microsoft.ClearScript;
using Microsoft.ClearScript.V8;
using System;
namespace Implem.Pleasanter.Libraries.ServerScripts
{
public class ClearScriptEngineWrapper : IScriptEngine
{
private V8ScriptEngine v8ScriptEngine;
public void SetContinuationCallback(Func<bool> callback)
{
v8ScriptEngine.ContinuationCallback = callback == null
? null
: new ContinuationCallback(callback);
}
public ClearScriptEngineWrapper(bool debug)
{
var flags = V8ScriptEngineFlags.EnableDateTimeConversion;
if (debug)
{
flags |= V8ScriptEngineFlags.EnableDebugging
| V8ScriptEngineFlags.EnableRemoteDebugging;
}
v8ScriptEngine = new V8ScriptEngine(flags);
}
public void AddHostType(Type type)
{
v8ScriptEngine?.AddHostType(type);
}
public void AddHostObject(string itemName, object target)
{
v8ScriptEngine?.AddHostObject(itemName, target);
}
public void Execute(string code, bool debug)
{
v8ScriptEngine?.Execute(
new DocumentInfo()
{
Flags = debug ? DocumentFlags.AwaitDebuggerAndPause : DocumentFlags.None
},
code);
}
public object Evaluate(string code)
{
return v8ScriptEngine?.Evaluate(code);
}
public void Dispose()
{
v8ScriptEngine?.Dispose();
}
}
}
プロパティ名の命名規則でIDEから怒られるところが1ヶ所ありますが、前例踏襲で突っ走ります。
C#スクリプト用のラッパーを作成
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;
}
}
}
やっていることはとても単純です。基本的な配列やリストなどの処理をおこなえるようにするためにCreateScriptOptionsでリファイレンスの追加、既存のmodelやcontextなどにアクセス出来るようにするためにCreateGlobalsでオブジェクトを生成しています。
あとは、C#スクリプトの実行部分は非同期にしたいので、既存のインタフェースの実装と合わせるために1ステップ別のメソッドを挟んでコードが実行されるようにしています。
ファクトリを作成
パラメータに設定されたものを見て、どのエンジンを使用するかを設定するようにします。
using Implem.DefinitionAccessor;
using static Implem.ParameterAccessor.Parts.Script;
namespace Implem.Pleasanter.Libraries.ServerScripts
{
public static class ScriptEngineFactory
{
public static IScriptEngine Create(bool debug, ServerScriptEngineTypes engineType = ServerScriptEngineTypes.ClearScript)
{
var defaultEngine = Parameters.Script.ServerScriptEngineType;
switch (engineType)
{
case ServerScriptEngineTypes.ClearScript:
return new ClearScriptEngineWrapper(debug);
default:
case ServerScriptEngineTypes.CSharpScript:
return new CSharpScriptEngineWrapper(debug);
}
}
}
}
ScriptEngineを編集
最後にScriptEngineの既存の実装を編集します。
using System;
using static Implem.ParameterAccessor.Parts.Script;
namespace Implem.Pleasanter.Libraries.ServerScripts
{
public class ScriptEngine : IDisposable
{
private readonly IScriptEngine ScriptEngineImpl;
public Func<bool> ContinuationCallback
{
set => ScriptEngineImpl?.SetContinuationCallback(value);
}
public ScriptEngine(bool debug, ServerScriptEngineTypes engineType = ServerScriptEngineTypes.ClearScript)
{
ScriptEngineImpl = ScriptEngineFactory.Create(debug, engineType);
}
public void AddHostType(Type type)
=> ScriptEngineImpl?.AddHostType(type);
public void AddHostObject(string itemName, object target)
=> ScriptEngineImpl?.AddHostObject(itemName, target);
public void Dispose()
=> ScriptEngineImpl?.Dispose();
public void Execute(string code, bool debug)
=> ScriptEngineImpl?.Execute(code, debug);
public object Evaluate(string code)
=> ScriptEngineImpl?.Evaluate(code);
}
}
切替のために引数を変更
先ほど作成したScriptEngineメソッドには実行エンジンを切り替えるための引数としてengineTypeを追加しています。これにパラメータを渡して、実行エンジンの切替を実現します。
-using (var engine = new ScriptEngine(debug: debug))
+using (var engine = new ScriptEngine(debug: debug, engineType: Parameters.Script.ServerScriptEngineType))
既存実装の関数読込部分に条件分岐を挿入
ClearScript用のメソッド群をハードコーティングしている部分があるので、条件分岐をくわえてロードしないように変更します。(本番運用をおこなう場合は、これらも別でC#に書き換えたものを用意する必要あり)
engine.ContinuationCallback = model.ContinuationCallback;
-engine.Execute(ServerScriptJsLibraries.ScriptInit(), debug: false);
switch (Parameters.Script.ServerScriptEngineType)
+{
+ default:
+ case ServerScriptEngineTypes.ClearScript:
+ engine.Execute(ServerScriptJsLibraries.ScriptInit(), debug: false);
+ break;
+ case ServerScriptEngineTypes.CSharpScript:
+ //現状は未実装
+ break;
+}
engine.AddHostType(typeof(Newtonsoft.Json.JsonConvert));
engine.AddHostObject("context", model.Context);
engine.AddHostObject("grid", model.Grid);
engine.AddHostObject("model", model.Model);
engine.AddHostObject("saved", model.Saved);
engine.AddHostObject("depts", model.Depts);
engine.AddHostObject("groups", model.Groups);
engine.AddHostObject("users", model.Users);
engine.AddHostObject("columns", model.Columns);
engine.AddHostObject("siteSettings", model.SiteSettings);
engine.AddHostObject("view", model.View);
engine.AddHostObject("items", model.Items);
engine.AddHostObject("hidden", model.Hidden);
engine.AddHostObject("responses", model.Responses);
engine.AddHostObject("elements", model.Elements);
engine.AddHostObject("extendedSql", model.ExtendedSql);
engine.AddHostObject("notifications", model.Notification);
if (!Parameters.Script.DisableServerScriptHttpClient)
{
engine.AddHostObject("httpClient", model.HttpClient);
}
engine.AddHostObject("utilities", model.Utilities);
engine.AddHostObject("logs", model.Logs);
if (!Parameters.Script.DisableServerScriptFile)
{
engine.AddHostObject("_file_cs", model.File);
- engine.Execute(model.File.Script(), debug: false);
+ switch (Parameters.Script.ServerScriptEngineType)
+ {
+ default:
+ case ServerScriptEngineTypes.ClearScript:
+ engine.Execute(model.File.Script(), debug: false);
+ break;
+ case ServerScriptEngineTypes.CSharpScript:
+ //現状は未実装
+ break;
+ }
}
engine.AddHostObject("_csv_cs", model.Csv);
-engine.Execute(model.Csv.Script(), debug: false);
-engine.Execute(ServerScriptJsLibraries.Scripts(), debug: false);
+switch (Parameters.Script.ServerScriptEngineType)
+{
+ default:
+ case ServerScriptEngineTypes.ClearScript:
+ engine.Execute(model.Csv.Script(), debug: false);
+ engine.Execute(ServerScriptJsLibraries.Scripts(), debug: false);
+ break;
+ case ServerScriptEngineTypes.CSharpScript:
+ //現状は未実装
+ break;
+}
engine.Execute(
code: scripts.Select(script =>
ProcessedBody(
ss: ss,
script: script)).Join("\n"),
debug: debug);
デバッグしてみる
ここまで来れば実装は一通り完了です。動かしてみましょう。

SiteId:0のホーム画面には無事到達出来ました。では、早速サイトを作ってコードを書いてみます。条件は画面表示の前になります。
model.Set("Title", System.DateTime.Now);
お、動きましたね。今回はここまでです。
まとめ
今回はプリザンターのサーバスクリプトでC#スクリプトを使えるようにできるか試してみました。ひとまずは動作するところまでは確認ができました。
プロダクト環境にもっていくには、あとはClearScript用に追加されている組み込み関数群の移植など追加が必要になりますが、ここまで作ってしまえばあとは頑張るだけですね。
型定義が厳密などおいしいところが多いのでタイミングをみて頑張ろうかなとは考えています。
