はじめに
プリザンターを本格的に運用しはじめると、テーブルごとのスクリプトやサーバスクリプトの中に「同じ値」が何度も登場します。
- ステータスの値(
900= 完了、200= 進行中) - 分類項目に入れるコード(
"A001"= 承認済み) - 連携先のサイトID
- 外部システムのエンドポイントやタイムアウト値
こうしたマジックナンバーは、最初は 1 か所に書くだけですが、テーブルが増えるにつれてコピーが増殖します。値を変えたいときに全テーブルのスクリプトを開いて探し回る作業になり、直し漏れが必ず出ます。
今回は拡張サーバスクリプトに定数の実体を 1 か所だけ置き、そこからクライアントへまとめて配って、拡張スクリプトに用意した読み出しヘルパー経由で参照する構成を紹介します。テーブル管理画面のスクリプトには「他のスクリプトを読み込む」仕組みがありませんが、拡張機能側には共有と取り込みの仕組みが用意されています。本体コードの改変は不要です。
バージョン 1.5.7.1 を対象にしています
全体像
定数の実体は共有サーバスクリプトの 1 ファイルだけに置きます。サーバサイドとクライアントサイドはスクリプトエンジンが別なので、クライアントへは 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 = '完了済みのレコードは更新できません';
}
なぜ参照できるのか
サーバスクリプトの実行部分を見てみましょう。共有スクリプトの本文が、実行対象のスクリプトより前に連結されて 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);
同じスクリプトエンジンの同じスコープで評価されるので、共有スクリプトのトップレベルで宣言した 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();
拡張サーバスクリプトはテーブル管理画面で設定したサーバスクリプトと同じリストにマージされてから、この振り分けを通ります。
共有スクリプトを使うときの注意点
実装を追うと、いくつか押さえておきたい挙動が見えてきます。
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}})();";
}
Functionalize は本文を即時関数で包む処理なので、これが適用されると定数がスコープの外から見えなくなってしまいます。共有スクリプトでは Functionalize を false にしておきましょう。同時に TryCatch も効かないため、共有スクリプトの例外は握られません。宣言だけを置き、実行時に失敗しうる処理は書かないのが安全です。
3. 名前の二重宣言でスクリプト全体が落ちる
共有スクリプトと対象スクリプトは 1 回の Execute にまとめられるので、同じ名前を const で二重宣言すると SyntaxError になり、そのタイミングのサーバスクリプトがすべて動かなくなります。定数用の名前空間を 1 つ(例では APP_CONST)に絞り、テーブル側では宣言しないルールにしておきます。
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>();
サーバスクリプトの読み込みでは 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));
拡張サーバスクリプトには Background の項目自体がないため、ファイルで配置した共有スクリプトはバックグラウンド実行時には読み込まれません。バックグラウンドで定数を使いたい場合は、テーブル管理画面のサーバスクリプト側で「バックグラウンド」と「共有」を有効にしたスクリプトを用意します。
バックグラウンド側の共有スクリプトは 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
サーバサイドの定数が 1 か所にまとまったので、次はこれをクライアントへ渡します。ここでクライアント用に定数を書き写してしまうと二重管理に戻ってしまうので、サーバから丸ごと配ってクライアントは読むだけにします。
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());
}
追加した値は 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など)と衝突しない名前を付けてください - 値は必ず
JSON.stringifyした文字列で渡します。サーバスクリプトのオブジェクトをそのまま渡すと、サーバ側でのシリアライズ結果が意図した形になりません - クライアントで使わない定数(API キーや接続文字列など)は配らないようにします。Hidden 要素は誰でも読めます
クライアントサイド:拡張スクリプトに読み出しヘルパーを作る
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の呼び出し側は書き換えずに済む
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));
こちらは 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 を差し替えるだけで両対応にもできます。
まとめ
プリザンターのスクリプトで使う定数は、拡張機能を使えば実体を 1 か所にまとめられます。
-
定数の実体は拡張サーバスクリプトに
Shared: trueを設定して置く。トップレベルで宣言したconstがすべてのテーブルのサーバスクリプトから参照できる -
明示的に取り込みたい場合は
//Include: 名前を行頭に書く。依存関係がスクリプト本文に残るので追いやすい -
クライアントへは
BeforeOpeningPageのサーバスクリプトからhidden.Addで JSON をまとめて配る。Hidden 要素はscriptタグより前に出力される -
クライアント側は拡張スクリプトに
00_Constants.jsとして読み出しヘルパーを置き、appConst.get('Status.Closed')の形で参照する。パース・存在チェック・既定値の扱いが 1 か所で済む -
Ajax 後も値を差し替えたい場合は
SetMemoryに切り替える。参照できるのはDOMContentLoaded後に限られる
注意点としては、共有スクリプトで Functionalize を有効にすると定数がスコープ外になること、ファイル配置の共有スクリプトはバックグラウンドサーバスクリプトに届かないこと、SpecifyByName を true にすると読み込まれないこと、hidden.Add は同じキーの二重追加で例外になることを押さえておけば十分でしょう。
定数を 1 か所に寄せておくと、ステータス値の追加やサイトIDの差し替えが 1 ファイルの修正で済みます。テーブルが 10 個を超えたあたりから効果が実感できるので、早めに仕込んでおくことをおすすめします。