スクリプトとサーバースクリプトで定数を共通化
ステータス値やサイト ID などの定数は、拡張サーバースクリプト(Shared: true)に実体を 1 か所だけ置き、BeforeOpeningPage のサーバースクリプトから hidden.Add でクライアントへ配り、拡張スクリプトの読み出しヘルパー経由で参照する構成にすると、本体コードを改変せずに共通化できます。 テーブル管理画面のスクリプトには「他のスクリプトを読み込む」仕組みがありませんが、拡張機能側には共有と取り込みの仕組みが用意されています。
対象バージョン
バージョン 1.5.7.1 を対象にしています。
何を共通化するか
テーブルごとのスクリプトやサーバースクリプトには、次のような同じ値が何度も登場しがちです。
- ステータスの値(
900= 完了、200= 進行中) - 分類項目に入れるコード(
"A001"= 承認済み) - 連携先のサイト ID
- 外部システムのエンドポイントやタイムアウト値
テーブルが増えるとこれらのコピーも増え、値を変えるときに全テーブルのスクリプトを探し回ることになり、直し漏れが発生します。
全体像
サーバサイドとクライアントサイドはスクリプトエンジンが別なので、クライアントへは Hidden 要素に載せて配ります。
図を読み込み中…
| 経路 | 使う機能 | 届く先 |
|---|---|---|
| 共有サーバースクリプト | 拡張サーバースクリプトの Shared | すべてのテーブルのサーバースクリプト |
| インクルード | サーバースクリプト本文の //Include: | 明示的に取り込んだサーバースクリプトのみ |
| クライアントへの配布 | hidden.Add と拡張スクリプトのヘルパー | 拡張スクリプト・テーブルのスクリプト(ブラウザ側) |
サーバサイド: 拡張サーバースクリプトを共有スクリプトにする
拡張サーバースクリプトは App_Data/Parameters/ExtendedServerScripts/ に、定義ファイル(.json)と本体(.json.js)の 2 ファイルで配置します。
{
"Name": "Constants",
"Description": "全テーブル共通の定数定義",
"SiteIdList": null,
"Controllers": null,
"Actions": null,
"Shared": true,
"Functionalize": false,
"TryCatch": false
}const APP_CONST = Object.freeze({
Status: Object.freeze({
Draft: 100,
InProgress: 200,
Approved: 300,
Closed: 900
}),
Approval: Object.freeze({
Approved: 'A001',
Rejected: 'A002'
}),
SiteId: Object.freeze({
Customers: 12345,
Orders: 12346
}),
Api: Object.freeze({
TimeoutMs: 10000
})
});
// 定数と一緒に共通関数を置いても構いません
function isClosed(status) {
return status === APP_CONST.Status.Closed;
}これだけで、すべてのテーブルのサーバースクリプトから APP_CONST を参照できます。
if (model.Status === APP_CONST.Status.Closed && !context.HasPrivilege) {
context.Error('完了済みのレコードは更新できません');
}context.Error はプロパティではなく、メッセージを引数に取るメソッドです(ServerScriptModelContext.cs)。代入の形(context.Error = '...')ではエラーを設定できないため、メソッドとして呼び出します。
参照できる理由
サーバースクリプトの実行時、共有スクリプトの本文は実行対象のスクリプトより前に連結され、1 回の Execute に渡されます。
engine.Execute(
code: new[]
{
(sharedScripts ?? []).Select(script =>
script.Body).Join("\n"),
scripts.Select(script =>
ProcessedBody(
ss: ss,
script: script)).Join("\n")
}
.Where(o => !o.IsNullOrEmpty())
.Join("\n"),
debug: debug);ServerScriptUtilities.cs#L1165-L1177
同じスクリプトエンジンの同じスコープで評価されるため、共有スクリプトのトップレベルで宣言した const や関数がそのまま見えます。
共有スクリプトとそれ以外の振り分けは次の部分です。
var scripts = allScripts
.Where(where)
.Where(script => script.Shared != true) // 共有スクリプト以外でフィルタ
.ToArray();
if (!scripts.Any())
{
return null;
}
var sharedScripts = allScripts
.Where(script => script.Shared == true)
.ToArray();ServerScriptUtilities.cs#L1261-L1271
拡張サーバースクリプトは、テーブル管理画面で設定したサーバースクリプトと同じリストにマージされてから、この振り分けを通ります(SiteSettings.cs#L6176-L6204)。
共有スクリプトの注意点
| # | 注意点 | 対策 |
|---|---|---|
| 1 | そのタイミングで動くサーバースクリプトが 1 本もないと、共有スクリプトも評価されない | 共有スクリプトには副作用のある処理を書かず、定数の宣言にとどめる |
| 2 | Functionalize と TryCatch は共有スクリプトには効かない | Functionalize は false にする。実行時に失敗しうる処理は書かない |
| 3 | 同じ名前を const で二重宣言すると SyntaxError になり、そのタイミングのサーバースクリプトがすべて動かなくなる | 定数用の名前空間を 1 つ(例: APP_CONST)に絞り、テーブル側では宣言しない |
| 4 | SpecifyByName を true にすると読み込まれない | SpecifyByName は true にしない(SiteIdList や Controllers による絞り込みは使える) |
| 5 | ファイル配置の共有スクリプトはバックグラウンドサーバースクリプトに届かない | テーブル管理画面側で「バックグラウンド」と「共有」を有効にしたスクリプトを用意する |
1. 共有スクリプトだけでは実行されない
上のコードの if (!scripts.Any()) で早期リターンしているため、そのタイミングで動く(共有でない)サーバースクリプトが 1 本もないと、共有スクリプトも評価されません。定数を置いておく用途では問題になりませんが、共有スクリプトに副作用のある処理を書いても走らないことがあります。
2. Functionalize と TryCatch は共有スクリプトには効かない
ProcessedBody は実行対象のスクリプトにだけ適用されます。
private static string ProcessedBody(SiteSettings ss, ServerScript script)
{
var body = script.Body;
if (script.Functionalize == true)
{
body = $"(()=>{{\n{script.Body}\n}})();";
}ServerScriptUtilities.cs#L1196-L1213
Functionalize は本文を即時関数で包む処理なので、適用されると定数がスコープの外から見えなくなります。共有スクリプトでは Functionalize を false にしてください。また TryCatch も効かないため、共有スクリプトの例外は捕捉されません。宣言だけを置き、実行時に失敗しうる処理は書かないのが安全です。
3. 名前の二重宣言でスクリプト全体が落ちる
共有スクリプトと対象スクリプトは 1 回の Execute にまとめられるため、同じ名前を const で二重宣言すると SyntaxError になり、そのタイミングのサーバースクリプトがすべて動かなくなります。
4. SpecifyByName は true にしない
拡張機能の絞り込み条件は次の共通処理で評価されます。
return extensions
?.Where(o => !o.SpecifyByName || o.Name == name)
.Where(o => MeetConditions(o.DeptIdList, deptId))
.Where(o => o.GroupIdList?.Any() != true
|| groups?.Any(groupId => MeetConditions(o.GroupIdList, groupId)) == true)
.Where(o => MeetConditions(o.UserIdList, userId))
.Where(o => MeetConditions(o.SiteIdList, siteId))
.Where(o => MeetConditions(o.IdList, id))
.Where(o => MeetConditions(o.Controllers, controller))
.Where(o => MeetConditions(o.Actions, action))
.Where(o => MeetConditions(o.ColumnList, columnName))
.Where(o => !o.Disabled)
.Cast<T>();ExtensionUtilities.cs#L216-L230
サーバースクリプトの読み込みでは name に null が渡るため、SpecifyByName を true にすると読み込まれません。一方、SiteIdList や Controllers は使えるので、「特定のテーブルだけ別の定数を配る」といった絞り込みは可能です。
5. バックグラウンドサーバースクリプトには届かない
バックグラウンドサーバースクリプトは別の経路で実行され、共有スクリプトもテナントに登録されたスクリプトの中から選ばれます。
var scripts = new List<ServerScript>();
scripts.AddRange(inScripts
.Scripts
.Where(s => s.Disabled != true && s.Background == true && s.Shared == true));BackgroundServerScriptJob.cs#L45-L48
拡張サーバースクリプトには Background の項目自体がないため、ファイルで配置した共有スクリプトはバックグラウンド実行時には読み込まれません。バックグラウンドで定数を使いたい場合は、テーブル管理画面のサーバースクリプトで「バックグラウンド」と「共有」を有効にしたスクリプトを用意します。
WARNING
バックグラウンド側の共有スクリプトは ProcessedBody を通る経路に入ります。Functionalize を有効にすると定数が見えなくなるので、こちらでも false にしてください。
//Include: で明示的に取り込む
全スクリプトに暗黙で配られるのを避けたい場合や、依存関係をスクリプト側に明記したい場合は //Include: を使います。サーバースクリプト本文の行頭に //Include: 名前 と書くと、その名前のサーバースクリプトの本文が展開されます。
//Include: Constants
if (model.ClassA === APP_CONST.Approval.Approved) {
model.Status = APP_CONST.Status.Approved;
}展開処理は次のとおりです。
foreach (var line in body.Split('\n'))
{
if (line.StartsWith("//Include:"))
{
var name = line.Substring(line.IndexOf(":") + 1).Trim();
var includeBody = serverScripts
.Where(o => o.Name == name)
.Select(o => o.Body)
.Join("\n");- 取り込み元の
Nameが必須です。拡張サーバースクリプトのNameは JSON の"Name"から取るので、必ず設定します //Include:は行頭から書きます。インデントすると単なるコメント扱いになります- 名前が一致するスクリプトが複数あると、すべてが連結されて展開されます
- 再帰展開の深さは
Script.jsonのServerScriptIncludeDepthLimit(既定値10)で制限されています
共有とインクルードの使い分け
| 観点 | Shared | //Include: |
|---|---|---|
| 参照側の記述 | 不要 | 各スクリプトに 1 行必要 |
| 依存関係の見え方 | スクリプトを見ても分からない | 本文に明示される |
| 適用範囲 | 対象条件に合うすべてのテーブル | 書いたスクリプトのみ |
| 名前の衝突 | 全スクリプトに影響 | 取り込んだスクリプトのみに影響 |
全社共通の定数は Shared、特定業務でだけ使う定数セットは //Include: と分けると扱いやすくなります。
クライアントへ配る: hidden.Add
クライアント用に定数を書き写すと二重管理に戻ってしまうため、サーバから丸ごと配り、クライアントは読むだけにします。
hidden はサーバースクリプトに公開されているホストオブジェクトで、キーと値を追加すると input type="hidden" として HTML に出力されます。
public string Get(string key = null)
{
return data[key];
}
public void Add(string key = null, object value = null)
{
data.Add(key, value.ToString());
}ServerScriptModelHidden.cs#L18-L26
追加した値は HiddenServerScript で、キーをそのまま id にした Hidden 要素として出力されます。
private static HtmlBuilder HiddenServerScript(
this HtmlBuilder hb,
Context context,
SiteSettings ss,
ServerScriptModelRow serverScriptModelRow)
{
serverScriptModelRow?.Hidden?.ForEach(hidden => hb
.Hidden(controlId: hidden.Key, value: hidden.Value));配布用の拡張サーバースクリプトを 1 本用意し、共有スクリプトで定義した APP_CONST を JSON 化して流し込みます。
{
"Name": "PublishConstants",
"Description": "共通定数をクライアントへ配布",
"BeforeOpeningPage": true,
"Shared": false,
"Functionalize": true,
"TryCatch": true
}hidden.Add('AppConst', JSON.stringify(APP_CONST));これで全テーブルの画面に id="AppConst" の Hidden 要素が出力されます。定数を増やしても、書き換えるのは共有スクリプトの APP_CONST だけです。
hidden.Add の注意点
- 実装は
Dictionary.Addなので、同じキーを 2 回追加すると例外になります。配布用スクリプトは 1 本に絞り、TryCatch: trueを付けておくと安全です value.ToString()を呼ぶため、nullを渡すと例外になりますhidden.Getは存在しないキーで例外になります。キーの有無を確認する用途には向きません- キーはそのまま要素の
idになります。プリザンター標準の Hidden 要素(SiteId、Columnsなど)と衝突しない名前を付けてください(標準の Hidden 要素はスクリプトで使えるシステム変数を参照) - 値は必ず
JSON.stringifyした文字列で渡します。サーバースクリプトのオブジェクトをそのまま渡すと、サーバ側でのシリアライズ結果が意図した形になりません
秘密情報は配らない
Hidden 要素は誰でも読めます。クライアントで使わない定数(API キーや接続文字列など)は配布しないでください。
クライアントサイド: 拡張スクリプトに読み出しヘルパーを置く
Hidden 要素を読む処理をテーブルごとのスクリプトに書くと、JSON.parse と存在チェックがあちこちに散らばります。拡張スクリプトに読み出しヘルパーを 1 本用意し、テーブルのスクリプトからはヘルパー経由で参照します。
(function () {
var HIDDEN_ID = '#AppConst';
var cache;
function load() {
if (cache !== undefined) {
return cache;
}
var raw = $(HIDDEN_ID).val();
if (!raw) {
// 拡張スクリプトの絞り込みなどで配布されていない場合
cache = Object.freeze({});
return cache;
}
try {
cache = Object.freeze(JSON.parse(raw));
} catch (e) {
console.error('appConst: JSONの解析に失敗しました', e);
cache = Object.freeze({});
}
return cache;
}
// 'Status.Closed' のようなドット区切りのパスで取り出す
function get(path, fallback) {
var value = String(path || '')
.split('.')
.reduce(function (obj, key) {
return (obj === null || obj === undefined) ? undefined : obj[key];
}, load());
return value === undefined ? fallback : value;
}
window.appConst = {
get: get,
all: load,
reload: function () {
cache = undefined;
return load();
}
};
})();テーブルのスクリプトからは次のように使います。
$p.events.on_editor_load = function () {
if ($p.getControl('Status').val() === String(appConst.get('Status.Closed'))) {
$('#Results_Body').prop('disabled', true);
}
};第 2 引数に既定値を渡せるので、定数が配られていない画面でも動作を止めずに済みます。
var timeout = appConst.get('Api.TimeoutMs', 10000);
var siteId = appConst.get('SiteId.Customers');
if (siteId === undefined) { return; }項目の値は文字列で返ってくるため、数値の定数と比較するときは String() で揃えるか Number() で寄せるかを決めておきます。
ヘルパーにしておくと、次の利点があります。
JSON.parseと存在チェック、パース失敗時のフォールバックが 1 か所に閉じ込められる- キーをタイプミスしても
undefinedが返るだけで、ReferenceErrorにならない - 読み出しが遅延評価なので、拡張スクリプトの評価タイミングに依存しない
- 配布経路を Hidden 要素から別の手段に変えても、
appConst.getの呼び出し側は書き換えずに済む
INFO
Object.freeze は浅い凍結です。入れ子のオブジェクトまで凍らせたい場合は、再帰的に Object.freeze を適用するヘルパーを追加してください。
拡張スクリプトに置く理由(読み込み順)
拡張スクリプトは App_Data/Parameters/ExtendedScripts/ に .js ファイルを置くだけで読み込まれ、ファイル名の昇順で評価されます。
var files = new DirectoryInfo(path)
.GetFiles("*.js")
.OrderBy(file => file.Name);ヘルパーのファイル名を 00_Constants.js にしておけば、他の拡張スクリプトより先に評価されるので、他の拡張スクリプトからも appConst.get を使えます。読み込まれた拡張スクリプトは 1 本の JavaScript に連結され、resources/scripts として配信されます。
さらに HTML への出力順では、拡張スクリプトの script タグはテーブル管理画面のスクリプトより前に置かれています。
.Script(src: Responses.Locations.Get(
context: context,
parts: $"resources/scripts?v={extendedScripts.Sha512Cng()}"
+ $"&site-id={context.SiteId}"
+ $"&id={context.Id}"
+ $"&controller={context.Controller}"
+ $"&action={context.Action}"),
nonce: context.Nonce,
_using: !extendedScripts.IsNullOrEmpty())
.Script(
script: ss.GetScriptBody(
context: context,
peredicate: o =>
o.All == true
&& o.Disabled != true),async も defer も付いていない同期スクリプトなので、テーブルのスクリプトが動く時点で window.appConst は必ず定義済みです。URL の v= は連結後のスクリプトのハッシュなので、内容を変えればキャッシュも自動で切り替わります。
Hidden 要素はスクリプトより前に出力される
ヘルパーが Hidden 要素を読めるのは、ページのテンプレートで Hidden 要素の出力(HiddenData)が script タグの出力(Scripts)より前にあるためです。
.HiddenData(
context: context,
ss: ss,
serverScriptModelRow: serverScriptModelRow)
.LoaderContainer(
context: context,
ss: ss)
.VideoDialog(
context: context,
ss: ss)
.Styles(
context: context,
ss: ss,
userStyle: userStyle)
.Htmls(
context: context,
ss: ss,
positionType: Settings.Html.PositionTypes.BodyScriptTop,
methodType: methodType)
.Scripts(
context: context,
ss: ss,
script: script,
userScript: userScript)serverScriptModelRow は ss.GetServerScriptModelRow で作られ、ここで BeforeOpeningPage のサーバースクリプトが実行されます。一覧・エディタのどちらもこの経路を通るため、両方の画面で Hidden 要素が出力されます。DOMContentLoaded を待つ必要がないので、ヘルパーをトップレベルで呼んでも値を取得できます。
補足: SetMemory で渡す方法
サーバースクリプトのレスポンスには SetMemory というメソッドもあり、$p のプロパティに値を書き込めます。
case 'SetMemory':
$p[target] = value;
break;context.AddResponse('SetMemory', 'appConstJson', JSON.stringify(APP_CONST));SetMemory は Ajax のレスポンスにも乗るため、更新後に値を差し替えたい場合に向いています。ただし全画面描画時は次のスクリプトで適用されるため、参照できるのは DOMContentLoaded の後です。
.Hidden(
controlId: "ServerScriptResponseCollection",
value: context.ResponseCollection.ToJson())
.Script(
script: OnDomReadyScript("$p.setByJson(undefined, undefined, $p.getData($('#MainForm')), $('#MainForm'), undefined, JSON.parse($('#ServerScriptResponseCollection').val()));"),
nonce: context.Nonce);| 方法 | 受け取り方 | 参照できるタイミング | Ajax 後の更新 |
|---|---|---|---|
hidden.Add | $('#キー名').val() | スクリプト評価時から | されない |
SetMemory | $p.キー名 | DOMContentLoaded 後 | される |
値が変わらない定数の配布には hidden.Add、リクエストごとに変わる値を渡したいときは SetMemory と使い分けます。ヘルパーの load を差し替えれば両対応にもできます。
まとめ
- 定数の実体は拡張サーバースクリプトに
Shared: trueを設定して置く。トップレベルで宣言したconstがすべてのテーブルのサーバースクリプトから参照できる - 明示的に取り込みたい場合は
//Include: 名前を行頭に書く - クライアントへは
BeforeOpeningPageのサーバースクリプトからhidden.Addで JSON をまとめて配る - クライアント側は拡張スクリプトに
00_Constants.jsとして読み出しヘルパーを置き、appConst.get('Status.Closed')の形で参照する - Ajax 後も値を差し替えたい場合は
SetMemoryを使う(参照できるのはDOMContentLoaded後)