はじめに
プリザンターのサーバスクリプトは、JavaScript エンジンとして ClearScript(V8)を使用しています。.NET の DateTime を JavaScript に渡すと、V8 は DateTimeKind に関係なく UTC の Date オブジェクトとして扱います。この ClearScript の制約により、サーバスクリプトの日時変換はフォーム入力や API とは根本的に異なる方式になっています。
本記事では、この仕組みがどんな影響を及ぼすのかを詳しく見ていきます。
| 回 | テーマ |
|---|---|
| 第 1 回 | アーキテクチャ編 — 全体設計と変換の仕組み |
| 第 2 回(本記事) | サーバスクリプト編 — SS 固有の変換方式と注意点 |
| 第 3 回 | API 編 — API でのタイムゾーンの取り扱い |
| 第 4 回 | タイムゾーン混在環境編 — ユーザーのタイムゾーンが混在する場合の問題と対策 |
バージョン 1.5.1.0 を対象にしています
通常サーバスクリプトと他の入力経路の違い
まず、通常サーバスクリプトの変換方式が他の経路とどう異なるのかを比較してみましょう。
フォーム・API・CSV では ToUniversal(context) でユーザー タイムゾーン を基準に変換しますが、通常サーバスクリプトでは ConvertTimeFromUtc(v, TimeZoneInfo.Local) という独自の変換が使われます。これが後述する要注意事項の根本原因です。
サーバスクリプトの日付フロー
入力フェーズ(DB → JavaScript)
サーバスクリプトが実行されるとき、DB から読み取った日時は Values() メソッドを通じて JavaScript 環境に渡されます。
通常サーバスクリプトでは、サーバーローカル タイムゾーン の生の DateTime がそのまま渡されます。
// 通常サーバスクリプト
ReadNameValue(
columnName: nameof(model.CreatedTime),
value: model.CreatedTime?.Value, // 生の DateTime(サーバーローカル タイムゾーン)
mine: mine),
JavaScript のエンジン(V8)は DateTime を受け取ると、UTC の Date オブジェクトとして扱います。ここに問題の種があります。
出力フェーズ(JavaScript → DB)
スクリプトで変更された日付値は、Date() メソッドを通じて書き戻されます。
private static DateTime Date(ExpandoObject data, string name)
{
var value = Value(data, name);
return value is DateTime dateTime
? TimeZoneInfo.ConvertTimeFromUtc(dateTime, TimeZoneInfo.Local)
: Types.ToDateTime(0);
}
ConvertTimeFromUtc は入力値を常に UTC として扱います。サーバーローカル タイムゾーン の値であっても、UTC として解釈して変換してしまいます。
問題が発生するケース
サーバー タイムゾーン が JST(+9)の環境で、日付をそのまま書き戻す場合を見てみましょう。
サーバー タイムゾーン が UTC の場合は ConvertTimeFromUtc(UTC, UTC) が恒等変換になるためズレは発生しません。サーバー タイムゾーン が UTC 以外の場合に問題が顕在化します。
日付コピーでの二重シフト
サーバスクリプト内で日付を別のフィールドにコピーする場合、さらに注意が必要です。
// サーバスクリプト内
model.DateA = model.StartTime;
計算式サーバスクリプトは安全
一方、計算式サーバスクリプトでは通常のフォーム入力と同じ変換方式が使われます。
計算式サーバスクリプトでは入力・出力が対称的に行われるため、日時のズレは発生しません。
| 項目 | 通常サーバスクリプト | 計算式サーバスクリプト |
|---|---|---|
| 入力の型 |
DateTime(サーバーローカル タイムゾーン) |
string(ユーザー タイムゾーン) |
| 出力の変換 | ConvertTimeFromUtc(v, Local) |
ToUniversal(context) |
| 対称性 | 非対称(入力の タイムゾーン と出力の変換基準が異なる) | 対称(入出力が同じ タイムゾーン 基準) |
$NOW() / $TODAY() と utilities.Today() の違い
日付取得の関数にもタイムゾーンの違いがあります。
計算式で使う $NOW() / $TODAY()
計算式サーバスクリプトで使える $NOW() / $TODAY() は、ユーザー タイムゾーン を考慮した文字列を返します。
| 関数 | 変換方法 | 戻り値 |
|---|---|---|
$NOW() |
UTC + ユーザー タイムゾーン オフセット | ユーザー タイムゾーン の日時文字列 |
$TODAY() |
UTC + ユーザー タイムゾーン オフセット(時刻部分を切り捨て) | ユーザー タイムゾーン の日付文字列 |
$NOW() / $TODAY() は BaseUtcOffset を使用しています。夏時間(DST)を持つタイムゾーンでは不正確になる可能性があります。
通常サーバスクリプトで使う utilities.Today()
通常サーバスクリプトの utilities.Today() は以下の処理を行います。
public DateTime Today()
{
return DateTime.Now.ToLocal(context: Context).Date.ToUniversal(context: Context);
}
-
DateTime.Nowでサーバーローカルの現在時刻を取得 -
.ToLocal(context)でユーザー タイムゾーン に変換 -
.Dateで日付部分のみ取得(00:00:00) -
.ToUniversal(context)でサーバーローカルに戻す
この値をサーバスクリプト内で代入すると、書き戻し時に Date() メソッドの ConvertTimeFromUtc が適用される点に注意が必要です。
文字列代入での値消失
サーバスクリプトで日付を文字列で代入すると、組み込み日付項目では値が消失します。
// NG: 値が消失する
model.StartTime = "2024/01/01";
これは Date() メソッドが value is DateTime チェックを行い、文字列の場合は Types.ToDateTime(0) を返すためです。
private static DateTime Date(ExpandoObject data, string name)
{
var value = Value(data, name);
return value is DateTime dateTime
? TimeZoneInfo.ConvertTimeFromUtc(dateTime, TimeZoneInfo.Local)
: Types.ToDateTime(0); // ← 文字列の場合はここに落ちる
}
安全パターンと危険パターンのまとめ
危険パターン
| パターン | 問題 |
|---|---|
model.StartTime = new Date(2024, 0, 1) |
Date() で UTC → サーバーローカル変換。サーバー タイムゾーン が UTC 以外ならズレる |
model.DateA = model.StartTime |
日付コピーで二重シフトが発生 |
model.StartTime = "2024/01/01" |
is DateTime チェック失敗で値が消失する |
安全パターン
| パターン | 理由 |
|---|---|
| 計算式サーバスクリプトを使う |
ToLocal / ToUniversal で対称変換 |
| サーバー タイムゾーン を UTC で運用する |
ConvertTimeFromUtc(UTC, UTC) が恒等変換になる |
ExpandoObject パスと API モデルパスの違い
サーバスクリプト内で日付を設定する方法は 2 つあり、変換処理が異なります。
組み込み日付項目(StartTime 等)
組み込み日付項目はどちらのパスでも同じ ConvertTimeFromUtc 変換が行われます。
拡張日付カラム(DateA 等)
拡張日付カラムでは、パスによって変換処理が異なります。
| パス | 変換フロー |
|---|---|
model.DateA(ExpandoObject) |
Date() → ConvertTimeFromUtc → 文字列 → SetValue(toUniversal: false)
|
model.Body.DateA(API モデル) |
value.ToStr() → SetValue(toUniversal: false) — タイムゾーン 変換なし |
同じ値を代入しても、パスによって異なるタイムゾーン処理が適用されます。
CompletionTime の特殊処理
CompletionTime(期限日)は、表示フォーマットが Ymd(日付のみ)の場合、内部的に +1 日のオフセットが加算される特殊な仕組みを持っています。
public static int DifferenceOfDates(string format, bool minus = false)
{
switch (format)
{
case "Ymd": return minus ? -1 : 1;
default: return 0;
}
}
計算式サーバスクリプトでは入力時に -1 日、書き戻し時に +1 日で正しく調整されますが、通常サーバスクリプトではこの調整が行われないため、CompletionTime を直接操作する際は注意が必要です。
まとめ
サーバスクリプトでのタイムゾーン処理のポイントをまとめます。
- 通常サーバスクリプトは
ConvertTimeFromUtcを使い、入力値を常に UTC として扱うため、サーバー タイムゾーン が UTC 以外の環境では日付ズレが発生する - 計算式サーバスクリプトは
ToLocal/ToUniversalを使い、フォーム入力と同じ対称的な変換を行うため安全 - 文字列での日付代入は、組み込み日付項目では値が消失する
-
ExpandoObjectパスと API モデルパスでは、拡張日付カラムの変換処理が異なる - サーバー タイムゾーン を UTC にしておくと、変換が恒等変換になり問題を回避できる