Skip to content

ClearScriptとJavaScript・.NETの境界 ​

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

プリザンターのサーバースクリプトは、V8で実行するJavaScriptに、ClearScriptを通じて.NETの業務オブジェクトを公開した環境です。JavaScriptの文法が別物になるのではなく、ホストから受け取る値の型、利用できるAPI、実行の寿命がブラウザやNode.jsと異なります。

このページではプリザンター1.5.8.1の実装と、同じオプションのClearScript 7.5.1で確認した変換結果を説明します。検証プログラムはWindows x64・.NET 10で実行しています。プリザンター固有の設定は本体ソース、型変換の実行例は独立した検証プログラムによる確認です。

V8・ClearScript・プリザンターの役割 ​

図を読み込み中…

V8単体をC++アプリケーションへ組み込む場合も、ホストがオブジェクトや関数を公開します。V8のIsolate・Context・Handleを扱い、GCによって移動するJavaScriptオブジェクトへの参照を管理する必要があります。ClearScriptは、その組み込みと.NETオブジェクトへのアクセスを.NET向けのAPIで提供します。V8の組み込みガイド

比較対象JavaScriptの実行ホストが追加する機能プリザンターとの違い
V8単体を組み込んだアプリV8C++の組み込み側が定義.NETの型変換やitemsなどは、別途実装が必要
ClearScriptのV8ScriptEngineV8AddHostObject・AddHostTypeで.NETの機能を公開公開する業務オブジェクトと運用設定はアプリ側が決定
プリザンターのサーバースクリプトClearScript経由のV8model・context・itemsなど本体が公開範囲、値の反映、実行条件と終了時の破棄を管理
ブラウザのスクリプトブラウザが搭載するエンジンDOM、ブラウザAPI、プリザンターの$p画面の$p.apiGetなどはサーバースクリプトへそのまま持ち込めない

「ピュアなV8」もホストの組み込み方で機能が変わるので、この比較はプリザンター固有のホスト機能を追加する前のV8を指します。V8単体はブラウザ全体やNode.js全体を意味しません。fetch・setTimeout・requireなどのAPIを、JavaScriptの文法と同じものとして扱わないでください。

ClearScriptの7.5.1はライブラリの版であり、ECMAScriptの版やChromeの版ではありません。利用できる構文は同梱V8の能力に依存します。プリザンターのパッケージ参照

プリザンターが選んでいるオプション ​

通常実行の設定は次の短いコードで決まります。

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

出典: Implem.Pleasanter/Libraries/ServerScripts/ScriptEngine.cs(20–32行・8d29c7b)

オプションプリザンターでの指定実装への影響
EnableDateTimeConversion常に有効.NETのDateTimeとJSのDateを双方向変換
DisableFloatNarrowingtrueJSの小数を.NETへ渡すとき、表現可能だからという理由でSingleに狭めない
EnableDebugging・EnableRemoteDebuggingデバッグ実行時だけ有効デバッガ接続を使えるようにする
DocumentFlags.AwaitDebuggerAndPause利用者のコードをデバッグ実行するとき指定デバッガの接続を待って実行を停止
EnableArrayConversion指定なし.NET配列を自動でJS配列にコピーする設定は使わない
EnableTaskPromiseConversion・EnableValueTaskPromiseConversion指定なし.NETのTaskを自動でJSのPromiseにする設定は使わない
EnableDynamicModuleImports指定なし動的importを有効にするフラグは使わない
MarshalUnsafeInt64AsBigInt・MarshalAllInt64AsBigInt指定なし.NETの64ビット整数をこれらのフラグでBigIntに変換しない
UseCaseInsensitiveMemberBinding指定なし.NETメンバー名の大文字・小文字を無視する設定は使わない
EnableStringifyEnhancements指定なしClearScriptのJSON拡張フラグは使わず、本体側の補助関数を用意
HideHostExceptions指定なしホスト例外をJSのError.hostExceptionから隠す設定は使わない

フラグの意味はClearScript 7.5.1の定義、デバッグ時の実行指定はScriptEngine.Executeで確認できます。

これらはScript.jsonに同名の設定を書けば切り替えられるオプションではありません。エンジン生成時に本体コードが指定しています。例えばTask変換を有効にするには、フラグだけでなく、Promiseの完了を待つ実行経路やエンジンの寿命も含めた本体側の設計が必要です。

公開した.NETオブジェクトは、JSの普通のオブジェクトとは異なる ​

プリザンターはAddHostObjectでcontext・model・users・columnsなどを渡し、AddHostTypeでNewtonsoft.Json.JsonConvertを公開しています。公開するオブジェクト

JS側でitems.Get(...)と書くと、ClearScriptが.NETメソッドへの呼び出しを橋渡しします。引数は.NET側の型とメソッドの候補に合わせて処理され、戻り値はJSへ渡せる表現に変換されます。メソッド名、引数の数と型、戻り値の実装を確かめる必要があります。

JSから見える対象実体扱うときの注意
model・saved本体が値を詰めたExpandoObject書き戻しはプリザンターのSetValues()で処理される
context・items・users専用のC#クラス公開されたメンバーを、定義どおりの名前・型で呼ぶ
users.Get(id)の戻り値専用のC#ユーザーモデル拡張項目はToJson()・ToString()で取り出す
.NETの配列・Listホスト側のコレクションJSのArrayメソッドがそのままあるとは限らない
JSON.parse(...)の戻り値JSのオブジェクト・配列・プリミティブJSとして加工した後、必要な形式でホストへ渡す

model.Title = '確認済み'も、一般的なJSオブジェクトの代入だけでDBが更新される仕組みではありません。変更を検知する動的モデルへ代入し、実行後に本体が項目ごとのsetterへ戻します。型・アクセス制御・実行条件との関係はmodelの反映条件で説明しています。

ユーザーの拡張項目を読む場合の例です。

js
var user = users.Get(context.UserId);
if (user) {
    var data = JSON.parse(user.ToString());
    var deptCode = (data.ClassHash || {}).ClassA;
    context.Log(deptCode || '未設定');
}

user.ClassHash.ClassAを直接読む形ではありません。取得モデルが明示的に提供するJSON変換を使います。取得・更新の具体例

.NETとの型の辻褄を合わせる ​

配列とListは、必要なときにJS配列へ変換する ​

検証プログラムで公開したint[]とList<int>は、次の結果になりました。

式int[]List<int>
Array.isArray(value)falsefalse
件数value.Lengthvalue.Count
value.mapundefinedJS配列のメソッドとしては使わない
Array.from(value)[10, 20][10, 20]

V8でJSのリテラル[10, 20]を作った場合はArray.isArrayがtrueになり、.length・.map()を使えます。この違いはClearScriptがJS配列の仕様を変更した結果ではなく、受け取った値が.NETのコレクションであることによります。

プリザンターでも、例えばusers.GetList()の戻り値は.NETの配列です。JS側でmap・filterなどを使いたい場合は変換してから操作します。

js
var list = users.GetList(null, 0, 10);
var names = Array.from(list).map(function (user) {
    return user.Name;
});
context.Log(names.join(', '));

Array.fromはこの配列・Listの検証で使えましたが、任意の.NETオブジェクトを変換する万能関数ではありません。また、配列に変換しても、各要素のC#オブジェクトまでJSの普通のオブジェクトになるわけではありません。

本体のCSV・ファイルAPIでも、返す際にV8ScriptEngine.Current.Script.Array.from()を明示的に使っている箇所があります。エンジン全体の自動変換ではなく、APIごとの戻り値で合わせています。CSVの配列変換、ファイルAPIの配列変換

小数のSingleへの縮小と、整数の変換は別 ​

JSから.NETのobject引数へ渡した数値の実測結果です。

渡すJSの値プリザンターと同じDisableFloatNarrowing=truefalseで比較
1Int32Int32
1.25DoubleSingle
1.23456789DoubleDouble

trueでも「すべての数値をDoubleにする」という意味ではありません。整数にできる値をInt32・Int64へ変換する経路は残り、Singleで表せる小数へ狭める経路だけを抑えます。ClearScriptの数値変換

この例はobject引数が実際に受け取った型です。特定の.NETメソッドに渡すときの最終的な型や、選ばれるオーバーロードがいつでもこの表になるとは限りません。プリザンターのAPIが求める型と、ホストメソッドの定義を基準にします。

Int64とdecimalの精度は自動で維持されない ​

同じ設定で.NETの9007199254740993LをJSへ渡すと、typeofはnumberになり、9007199254740992と等しい結果になりました。JS自体にはBigIntがありますが、プリザンターは.NETのInt64をBigIntへ変換するフラグを指定していません。

.NET decimalの0.1MもJSではnumberになり、0.2を足した結果は0.30000000000000004でした。DisableFloatNarrowingは、この精度問題を解決する設定ではありません。

桁の大きい識別子は文字列のまま扱い、金額など厳密な計算が必要な値は、JS側でNumberへ変換する前に計算方法を決めます。JSON.parseで大きなJSON数値を読んだ後にString(...)へ変換しても、失われた桁は戻りません。Int64の変換条件

DateTimeとDateは双方向変換するが、情報は完全には戻らない ​

EnableDateTimeConversionにより、ホストのUTC日時はJSのDateとして受け取れます。逆にJSのDateを.NETのDateTime引数へ渡した検証では、KindがUtcになりました。ClearScriptの定義でも、JSから作ったDateTimeはUTCになると明記されています。変換の定義

JSのDateは.NETのDateTime.Kindや元のタイムゾーン情報を、そのまま保持する型ではありません。プリザンターでは、この変換に加えて、本体側の保存・表示時の変換が入ります。日付の扱いとタイムゾーンの考え方を合わせて確認してください。

JSONでホストの値をJSへ持ち直す ​

JSのJSON.stringifyと.NETのJsonConvert.SerializeObjectは、同じものではありません。検証では、ホストの配列だけをJSON.stringifyに渡すとundefinedになりました。配列をプロパティに持つC#オブジェクトを渡した場合も、他のプロパティはJSONになった一方、配列・Listのプロパティは出力されませんでした。Json.NETによるシリアライズでは、それらも含まれました。

使い分けの基準は次のとおりです。

対象変換方法
JSの普通のオブジェクト・配列JSON.stringify
JSON文字列をJSとして加工するJSON.parse
本体がJSON変換を提供する専用モデルToJson()・ToString()など、モデルの公開メソッド
.NETのオブジェクトを.NET側のルールでシリアライズする公開されているJsonConvert.SerializeObject。対象型の出力内容を確認

Json.NETの出力をJSON.parseすれば、JSのオブジェクトや配列として加工できます。ただし、型のメソッド・オブジェクトの同一性は引き継がれず、日時はJSON文字列になります。大きな数値の精度もJSのルールに従います。変換したコピーを書き換えるだけで元のホストオブジェクトが更新されるわけではありません。

本体のJSON補助関数にも適用範囲がある ​

プリザンターは$ps.JSON.stringifyと、その呼び出し先になる$p.JSON.stringifyを用意しています。サーバー側の$pは、このコードで作る小さなオブジェクトです。ブラウザ側のAPIクライアントとは別です。

csharp
                    stringify: function() { },
                    parse: function() { }};
            (function() {
                {
                    let js = JSON.stringify;
                    let clr = JsonConvert.SerializeObject;
                    $ps.JSON.stringify = (v, r, s) => js(v, r, s) || clr(v);
                }
                {
                    let js = JSON.parse;
                    let clr = JsonConvert.Parse;
                    $ps.JSON.parse = (v, r) => js(v, r) || clr(v);
                }
            })();
            $p = {};
            $p.JSON = {
                stringify: (v, r, s) => $ps.JSON.stringify(v, r, s)
            };
            """;
    }
}

出典: Implem.Pleasanter/Libraries/ServerScripts/ServerScriptJsLibraries.cs(26–46行・8d29c7b)

stringifyは、まずJSのJSON.stringifyを試し、その結果が偽と評価されたときだけJson.NETへフォールバックします。JS側で不完全でも空でないJSON文字列が返れば、Json.NETへ進みません。検証でも、配列だけは補えましたが、配列プロパティが抜けたオブジェクトの出力は補えませんでした。例外を捕まえて再試行するコードでもありません。

同じコードの$ps.JSON.parseにも注意が必要です。JSON.parseの結果を||で判定し、フォールバック先をJsonConvert.Parseにしています。標準のJSON.parse('0')は0を返しますが、本体の補助コードをそのまま実行した検証では$ps.JSON.parse('0')がTypeErrorになりました。JSON文字列をJSで解析する用途には、標準のJSON.parseを使ってください。

Promiseの構文と、非同期の実行管理を分ける ​

JavaScriptのPromiseやasync関数が使えることと、プリザンターがその完了を待つことは別です。

  • EnableTaskPromiseConversionは未指定なので、検証で公開した.NETのTask<int>はJSのPromiseではなく、thenもありませんでした。
  • httpClientは.NET内部のSendAsync(...).Resultなどで結果を待ち、サーバースクリプトには同期的に結果を返します。await httpClient.Get()と書いても、その通信を非同期にすることにはなりません。
  • 本体はExecuteで利用者のコードを実行し、usingを抜けるとエンジンを破棄します。返されたPromiseを追跡して完了まで待つ実装ではありません。

既に解決したPromiseの後続処理まで「絶対に動かない」と断定する必要もありません。問題は、通常のHTTPリクエストを越えて存続する非同期処理や、外部I/Oの完了待ちをこの経路が提供していないことです。

根拠はTask変換の条件、httpClientの待機、エンジンの寿命です。時間のかかる処理を画面から切り離す構成は非同期実行の手順で説明しています。

実行制限と.NET側の権限 ​

項目プリザンターでの実装
実行プロセス本体プロセス内でV8を実行し、.NETのホストメソッドを呼ぶ
実行時間ContinuationCallbackで期限を確認。既定はServerScriptTimeOut=10000
デバッグ時本体の継続判定はtrueを返し、通常の期限判定を行わない
HTTPDisableServerScriptHttpClient=falseが既定。無効にするとホストオブジェクトを公開しない
ファイルDisableServerScriptFile=trueが既定。有効にした場合だけファイルのホスト機能を公開
V8のヒープ制約本体のこのコンストラクタでは明示的なV8RuntimeConstraintsを渡していない

JSのループ中の期限確認と、.NET側で進行中のI/Oのキャンセルは同じではありません。HTTPの待機には別のタイムアウトがあります。タイムアウト

HideHostExceptionsも未指定なので、検証ではC#側の例外を捕まえたJSのErrorにhostExceptionがありました。例外をブラウザへ返すコードでは、内部の例外情報をそのまま利用者向けのメッセージにしないよう、表示する内容を決めます。

公開する機能を限定することと、OSレベルで隔離することは別です。 例えば公開済みのhttpClientやファイル機能は、本体が提供した.NET処理を呼び出します。ClearScriptを使っているだけで、任意の利用者コードを独立プロセス・独立権限で安全に実行できる、という意味にはなりません。

根拠は機能の公開条件、Script.jsonの既定値、デバッグ時の継続判定です。

検証プログラムを動かす ​

プリザンターのDBへ書き込まず、型変換だけを確認する独立したC#プログラムです。ホストの型は説明用に作ったもので、プリザンターの業務モデルそのものではありません。

Windows x64と.NET 10 SDKを用意し、2ファイルを同じフォルダに置いてdotnet run --project InteropProbe.csprojを実行します。初回はNuGetからパッケージを取得します。Linuxで試す場合は、ネイティブパッケージを実行環境に対応する同版のものへ置き換えます。

プロジェクトファイルの全文
xml
<Project Sdk="Microsoft.NET.Sdk">
  <PropertyGroup>
    <OutputType>Exe</OutputType>
    <TargetFramework>net10.0</TargetFramework>
    <ImplicitUsings>enable</ImplicitUsings>
  </PropertyGroup>
  <ItemGroup>
    <PackageReference Include="Microsoft.ClearScript.V8" Version="7.5.1" />
    <PackageReference Include="Microsoft.ClearScript.V8.Native.win-x64" Version="7.5.1" />
    <PackageReference Include="Newtonsoft.Json" Version="13.0.3" />
  </ItemGroup>
</Project>
検証コードの全文
csharp
using Microsoft.ClearScript.V8;
using Newtonsoft.Json;

foreach (var disableNarrowing in new[] { true, false })
{
    using var engine = new V8ScriptEngine(V8ScriptEngineFlags.EnableDateTimeConversion)
    {
        DisableFloatNarrowing = disableNarrowing
    };
    engine.AddHostObject("host", new ProbeHost());
    engine.AddHostType(typeof(JsonConvert));
    void Show(string name, string code) => Console.WriteLine(name + ": " + engine.Evaluate(code));

    Console.WriteLine("DisableFloatNarrowing=" + disableNarrowing);
    Show("numbers", "JSON.stringify([host.Kind(1), host.Kind(1.25), host.Kind(1.23456789)])");
    Show("array", "JSON.stringify([Array.isArray(host.Values), host.Values.Length, typeof host.Values.map, Array.from(host.Values).join(',')])");
    Show("list", "JSON.stringify([Array.isArray(host.List), host.List.Count, Array.from(host.List).join(',')])");
    Show("date", "JSON.stringify([host.UtcDate instanceof Date, host.UtcDate.toISOString(), host.DateKind(new Date('2026-10-12T00:00:00Z'))])");
    Show("Int64", "JSON.stringify([typeof host.Big, host.Big === 9007199254740992, typeof 1n])");
    Show("decimal", "JSON.stringify([typeof host.Amount, host.Amount + 0.2])");
    Show("JSON.stringify", "JSON.stringify(host)");
    Show("JsonConvert", "JsonConvert.SerializeObject(host)");
    Show("globals", "JSON.stringify([typeof window, typeof document, typeof fetch, typeof setTimeout, typeof require, typeof Promise])");
    Show("case", "JSON.stringify([typeof host.Kind, typeof host.kind])");
    Show("task", "JSON.stringify([host.TaskValue instanceof Promise, typeof host.TaskValue.then])");
    Show("exception", "(()=>{try{host.Fail(); return false;}catch(e){return !!e.hostException;}})()");
    Show("null", "JSON.stringify([host.Nothing === null, typeof host.Unknown])");
}

public class ProbeHost
{
    public int[] Values => new[] { 10, 20 };
    public List<int> List => new() { 10, 20 };
    public DateTime UtcDate => new(2026, 10, 12, 0, 0, 0, DateTimeKind.Utc);
    public long Big => 9007199254740993L;
    public decimal Amount => 0.1M;
    public object Nothing => null;
    public Task<int> TaskValue => Task.FromResult(42);
    public string Kind(object value) => value.GetType().Name;
    public string DateKind(DateTime value) => value.Kind.ToString();
    public void Fail() => throw new InvalidOperationException("probe");
}

関連ページ ​

変更履歴

第1版RAG連携とClearScriptを解説し、既存記事をサイト内で完結させる