JavaScriptでタイムゾーンの処理(2)

この記事は、「JavaScriptでタイムゾーンの処理」の第2部です。1部では、タイムゾーンとオフセットの概念、IANA timezone Databaseなどについて説明しました。ここでは、1部の内容をもとに、JavaScriptでのタイムゾーンについて説明します。未読の方は、まず1部をお読みいただくことをお勧めします。

1部で、JavaScriptのタイムゾーンのサポートが他言語と比較して、かなり不足していると述べました。しかし、微弱ながら、JavaScriptでもタイムゾーンを処理できる方法を提供しています。2部では、JavaScriptで対応しているタイムゾーン関連のAPIと、これらの限界について、さらに詳しく調べて、これらを補完できる方法を見つけたいと思います。

サーバー – クライアント環境でのタイムゾーン

まず、タイムゾーンを考慮して簡単なシナリオを考えてみよう。時間情報を扱う必要があるので、簡単なスケジュール管理プログラムを例に挙げると分かりやすいでしょう。クライアント環境で、ユーザーが登録ページから入力ボックスにスケジュール(日付や時間)を入力すると、その情報をサーバーに送信してDBに保存します。リストページでは、クライアントが再びサーバーから登録されたスケジュールの情報を受け取り、画面に表示することになります。

このとき注意すべき点は、サーバーに保存された同一データにアクセスするクライアントが、異なるタイムゾーンを持っている可能性があるということです。つまり、ソウルで「2017年3月10日午前11時30分」というスケジュールを登録して、ニューヨークでスケジュールを照会した場合「2017年3月10日午後9時30分」と表示されます。このように、さまざまなタイムゾーンのクライアント環境に対応するため、サーバーに保存されるデータは、タイムゾーンに影響されない絶対値でなければなりません。(絶対値をどのようなデータ形式でサーバーに保存するかは、各サーバーやデータベース環境によって異なるため、ここでは扱いません。)

一般的にこのようなデータは、UTCを基準にしたUNIX時間や、オフセット情報を含むISO-8601のような形態で送信します。上記の例、ソウルの2017年3月10日午後9時30分は、UNIX時間を用いると数字型の1489113000となり、ISO-8601を用いると文字列型の2017-03-10T11:30:00+09:00となります。

ブラウザ環境において、JavaScriptを使ってこれらの処理をする場合、ユーザーの入力値を上記のような形式に変換する作業と、上記のような形式のデータを受信してユーザーのタイムゾーンに合わせて変換する作業の両方を考慮する必要があります。よく使われる用語で表現すると、前者は解析(Parsing)、後者はフォーマット(Formatting)と言えるでしょう。JavaScriptでこれらをどのように処理するか確認しよう。

Node.jsを用いたサーバー環境では、JavaScriptを使用しても場合によってはクライアントから受信したデータを解析する作業が必要なことがあります。しかし通常、サーバー環境のタイムゾーンは、データベースと同様に設定されており、フォーマットを主にクライアントに委任するケースが多いため、ブラウザ環境よりは検討要素が少ないでしょう。この記事ではブラウザ環境を中心に説明します。

JavaScriptのDateオブジェクト

JavaScriptで日付や時刻に関連するすべての作業は、Dateオブジェクトを使用して処理します。ArrayやFunctionのように、ECMAScriptスペックで定義されたネイティブオブジェクトであり、主にC ++のようなネイティブコードで実装されます。APIは、MDN文書によく整理されているが、Javaのjava.util.Dateクラスから大いに影響を受けたと言われます。そのため不変(Immutable)データがないことや、Monthが0で始まるなどの不都合な特徴まで共有しています。

JavaScriptののDateオブジェクトは、内部的にUNIX時間のような絶対値で時間データを管理します。しかし、コンストラクタやparse()関数getHour()setHour()などのメソッドは、すべてのクライアントのローカルタイムゾーン(正確には、ブラウザが実行されるOSに設定されたタイムゾーン)に影響を受けます。したがって、ユーザーが入力したデータをそのまま使ってDateオブジェクトを作成したり、値を指定したりすると、そのデータはクライアントのローカルタイムゾーンを反映することになるでしょう。

1部で述べたように、JavaScriptは任意でタイムゾーンを変更できる方法がありません。したがって、ブラウザのタイムゾーン設定をそのまま反映しても問題ない状況だと仮定して説明を続けます。

ユーザーの入力値を利用したDateオブジェクトの作成

最初の例をもう一度使おう。タイムゾーンがソウルに設定され機器でユーザーが「2017年3月11日午前11時30分」を入力しました。この入力値を年度、月、日、時、分単位でそれぞれ数字の形態で2017、2、11、11、30で保存したと仮定します。(月は0から始まるので、3から1を引いた2になります。)コンストラクタを使うと各単位別の数値から簡単にDateオブジェクトを生成できます。

var d1 = new Date(2017, 2, 11, 11, 30);
d1.toString(); // Sat Mar 11 2017 11:30:00 GMT+0900 (KST)

d1.toString() の戻り値をみると、生成されたオブジェクトの絶対値は、オフセット+09:00(KST)基準の2017年3月11日11時30分であることがわかります。

コンストラクタを使ったもう1つの方法は、文字列データを利用するものです。Dateオブジェクトのコンストラクタに文字列の値を使用すると、内部的にDate.parse()を呼び出して適切な値を計算します。この関数は、RFC2888スペックとISO-8601スペックに対応します。しかし、MDNのDate.parse()のドキュメントにも記載されているように、このメソッドの結果値は、ブラウザごとに実装状態が異なるほか、文字列の形態によって正確な値を予測するのが困難なため、使用しないことを推奨しています。

たとえば2015-10-12 12:00:00のような文字列は、SafariやIEではNaNを返し、ChromeやFirefoxではローカルタイムゾーンの値を返し、場合によっては、他の環境ではUTC基準の値を返すこともあります。

サーバーデータを利用したDateオブジェクトの作成

サーバーからデータを受信した場合を考えてみよう。もしデータが数値のUNIX時間値であれば、コンストラクタを使って簡単にDateオブジェクトを生成できます。Dateコンストラクタは、引数として数字だけを受信すると、年数値と考えずに、ミリ秒単位のUNIX時間として認識する。(注:JavaScriptはUNIX時間値をミリ秒単位で扱います。つまり、秒単位で計算された値であれば、1000を掛けます。)以下のサンプルをみると、前のサンプルと同じ結果値を返すことがわかります。

var d1 = new Date(1489199400000);
d1.toString(); // Sat Mar 11 2017 11:30:00 GMT+0900 (KST)

では、UNIX時間ではなくISO-8601のような文字列型ならどうなるでしょうか。前述のとおり、Date.parse()メソッドは結果を信頼できないので、使用しないことを勧めています。しかし、ECMAScript 5版からISO-8601に対応しており、ECMAScript 5版に対応するIE9以上のブラウザでは、注意して使用すればISO-8601形式の文字列をDateコンストラクタに使用できます。
ここで注意する点は、最新ブラウザでない場合、最後にZの文字がなければ、UTC基準ではなくローカルタイムを基準に解析される場合があるということです。以下は、IE10で実行した結果です。

var d1 = new Date('2017-03-11T11:30:00');
var d2 = new Date('2017-03-11T11:30:00Z');
d1.toString(); // "Sat Mar 11 11:30:00 UTC+0900 2017"
d2.toString(); // "Sat Mar 11 20:30:00 UTC+0900 2017"

スペックによると、2つの結果値が同一であるにもかかわらず、 d1.toString()d2.toString()の結果値が異なっています。最新ブラウザでは、両方の結果は同一です。ブラウザのバージョン別に異なって解析される問題を防ぐには、タイムゾーンデータがない場合、文字列の最後に常にZを追加する必要があります。

サーバーに送信するデータを生成

これに先立って生成されたDateオブジェクトを使用すると、ローカルタイムゾーンを基準に日付や時刻を加えたり減算したり自由に演算できます。しかし、最後にもう一度サーバーにデータを送信するには、データを変換するプロセスが必要です。

UNIX時間形式の場合、getTime()メソッドを利用して簡単に実行できます。(以前と同様、ミリ秒単位ということに注意しよう。)

var d1 = new Date(2017, 2, 11, 11, 30);
d1.getTime(); // 1489199400000

ISO-8601形式の文字列の場合は、前述のとおり、ECMAScript 5版以降に対応するIE9以上のブラウザは、 toISOString()toJSON()メソッドを使用すると、ISO-8601形式の文字列を生成できます。(toJSON()JSON.stringify() など再帰的に呼び出されるときも使用できます。)両方のメソッドの結果値は同じですが、次のように無効データの処理だけ若干異なります。

var d1 = new Date(2017, 2, 11, 11, 30);
d1.toISOString(); // "2017-03-11T02:30:00.000Z"
d1.toJSON();      // "2017-03-11T02:30:00.000Z"

var d2 = new Date('Hello');
d2.toISOString(); // Error: Invalid Date
d2.toJSON();      // null

この他にもUTC基準の文字列を生成できるtoGMTString()toUTCString()メソッドがありますが、これらはRFC-1123標準に合った文字列を返却するので、必要に応じて使用できるでしょう。

DateオブジェクトにはtoString()toLocaleString()やこれらの拡張メソッドが既に存在しますが、主にローカルタイムゾーン基準の文字列を返却する目的で使用されており、ブラウザやOSの環境によって同じ結果が保証されないため、あまり有用ではありません。

ローカルタイムゾーンを変更する

これまで見てきたところ、JavaScriptでもある程度のタイムゾーンのサポートが行われているようです。しかし、もしOSのタイムゾーン設定に従わず、アプリケーション内でローカルタイムゾーンを手動で変更したい場合はどうすればよいでしょうか?また、さまざまなタイムゾーンでの時間を1つのアプリケーションで同時に表示したい場合はどうすればよいでしょうか?繰り返しますが、JavaScriptでローカルのタイムゾーンを手動で変更することができません。唯一の方法は、希望するタイムゾーンのオフセット値を調べて、オフセット値を加算または減算し、直接日付を計算することです。JavaScriptでできる最善の方法を説明します。

まず、現在のブラウザのタイムゾーン設定がソウルだと仮定しよう。ユーザーは、ソウル基準の2017年3月11日午前11時30分のデータを、ニューヨークのタイムゾーンとして表示されることを希望しています。そしてサーバーは、そのデータをミリ秒単位のUNIXの時間に送信しながら、(親切にも)ニューヨークのオフセットが-05:00であることも教えてくれました。では現在のローカルタイムゾーンのオフセットさえ分かれば、データを変換できます。

このときに使用できるメソッドがgetTimeZoneOffset()です。このメソッドは、JavaScriptでローカルタイムゾーン情報を知ることができる唯一のAPIで、現在のタイムゾーンのオフセットを分単位の数値で返却します。

var seoul = new Date(1489199400000);
seoul.getTimeZoneOffset(); // -540

戻り値-540はタイムゾーンが540分進んでいるという意味です。ソウルのオフセットが+09:00だと考えると符号が逆になっているのが分かります。この方式に基づいて、ニューヨークのオフセット-05:00を計算してみると、60 * 5 = 300になります。この840の差をミリ秒単位で補正して、新しいDateオブジェクトを作れば、そのオブジェクトのgetXXメソッドを利用して希望する形状のデータを作成できるでしょう。簡単なフォーマット関数を作成して結果を比較してみよう。

function formatDate(date) {
  return date.getFullYear() + '年' + 
    (date.getMonth() + 1) + '月' + 
    date.getDate() + '日' + 
    date.getHours() + '時' + 
    date.getMinutes() + '分';
}

var seoul = new Date(1489199400000);
var ny = new Date(1489199400000 - (840 * 60 * 1000));

formatDate(seoul);  // 2017年3月11日 11時30分
formatDate(ny);     // 2017年3月10日 21時30分

formatDate()の結果から、ソウルとニューヨークの時間帯に合わせて日付が正しく表示されました。意外と簡単に解決できたようです。これで対象の地域のオフセットさえ分かればローカルタイムゾーンを変更できるでしょうか?残念ながら答えは「NO」です。1部の内容「タイムゾーンは単にオフセットではなく、すべてのオフセットの変更履歴を含む一種のデータベースである」ということを思い出しましょう。正確なタイムゾーンの計算には、単に現時点のオフセットではなく、該当日付が指す時点でのオフセットが必要です。

ローカルタイムゾーン変更の問題

上記の例をもう少し発展させると、問題を簡単に発見できます。ユーザーがニューヨークの時間帯で、時間を確認した後、日付を11日から15日に変更します。DateオブジェクトのsetDate()メソッドを使用すると、他の項目は、維持したまま日付値だけ変更できます。

ny.setDate(15);
formatDate(ny);    // 2017年3月15日 21時30分

簡単に解決できたように見えますが、ここに落とし穴があります。まず、このデータをサーバーに送信する必要が場合、データ自体を変更したため、getTime()getISOString()などのメソッドを使用できません。したがって、サーバーに送信するには、前述の計算を逆にして、元のデータを演算する必要があります。

var time = ny.getTime() + (840 * 60 * 1000);  // 1489631400000

返却するときに逆に計算しなければならないのに、なぜあえて変換されたデータから日付を加えるのでしょうか?単に既存データに演算をしてフォーマットするときだけ、一時的に変換されたDateオブジェクトを生成してもよさそうです。しかし、もしソウル基準のDateオブジェクトから日付を15に変更した場合、11日からの15日になったので、4日(24 * 4 * 60 * 60 * 1000)追加されたものが、ニューヨークの基準では、10日から15日になったので5日(24* 5 * 60 * 60 * 1000)追加されます。つまり、日付の演算を行う際にも、当該オフセットを基準として進行しないと正確な演算ができません。

しかし、さらに重要なのは、単にオフセットを加えて削除するだけでは解決できない問題があるということです。ニューヨークのタイムゾーンは3月12日からサマータイム(DST)が適用されるため、2017年3月15日のオフセットが-05:00ではなく、-04:00にならなければなりません。つまり逆に演算するときは、現在よりも60分少ない780分にする必要があります。

var time = ny.getTime() + (780 * 60 * 1000);  // 1489627800000

逆にユーザーのローカルタイムゾーンがニューヨークであるとき、ソウルのタイムゾーンに基づいて日付演算をしようとすると、サマータイムが適用されて、計算が狂う問題が生じるでしょう。

結論として、単に伝達されたオフセット値だけでは希望するタイムゾーン基準で日付演算ができません。それだけではなく、サマータイムが適用される規則を知っていても、まだ弱点があります。つまり正確な日付演算のためにはIANA timezone Databaseのようにタイムゾーンの変更履歴が含まれた全体データが必要です。

解決するには、全体のタイムゾーンデータベースを保存して、Dateオブジェクトで日付や時刻のデータを取得するたびに、データベースから当該日付とタイムゾーンに合うオフセットを調べた後、上記のような演算を通じて結果を返す必要があります。もちろん、理論的に可能ですが、そのためにはあまりにも多くの努力が必要で、実際に変換されたデータが正しいか、テストにも時間がかかるでしょう。しかし落胆するのはやめましょう。JavaScriptタイムゾーンに問題はありますが、どのように解決できるか方法があります。

Moment Timezone

Momentは、JavaScriptで日付演算をするとき、ほとんど標準に位置するライブラリです。さまざまな日付演算とフォーマットのAPIを提供し、多くのユーザーに使用され、安定性も検証されています。またMoment Timezoneという拡張モジュールを使用すると、上述した問題を簡単に解決できます。この拡張モジュールは、実際のIANA timezone Databaseのデータを内蔵して、実際のオフセットを正確に計算し、タイムゾーンを変更して、フォーマットできる様々なAPIを提供します。

この記事では、ライブラリの使い方や構造については深く説明しません。ただし、上記の問題をどのように解決できるかチェックします。興味のある方は、Moment Timezoneの文書を参照してください。

さて、上記の問題をMoment Timezoneを使って解決してみよう。

var seoul = moment(1489199400000).tz('Asia/Seoul');
var ny = moment(1489199400000).tz('America/New_York');

seoul.format(); // 2017-03-11T11:30:00+09:00
ny.format();    // 2017-03-10T21:30:00-05:00

seoul.date(15).format();  // 2017-03-15T11:30:00+09:00
ny.date(15).format();     // 2017-03-15T21:30:00-04:00

結果を見ると、seoulオフセットはそのままである一方、nyオフセットは-05:00から-04:00に変更されたことがわかります。またformat()関数を使用すると、当該オフセットが正確に反映されたISO-8601文字列を簡単に取得できます。

結論

JavaScriptでサポートされるタイムゾーン関連のAPIと、問題点を説明しました。ローカルタイムゾーンを手動変更する必要がなければ、IE9以上のブラウザに限り、基本的なAPIだけでも必要な機能を実装できます。しかし、ローカルタイムゾーンを手動変更する必要がある場合、問題は複雑になります。サマータイムがなく、タイムゾーンポリシーに変更がほとんどない地域については、上述したgetTimezoneOffset()を活用したデータ変換によって不完全ですが実装可能です。しかし、さまざまな地域のタイムゾーンを正確に対応したい場合は、Moment Timezoneのようなライブラリを活用する方がよいでしょう。

1部の冒頭でも述べたように、この記事はタイムゾーンの実装の失敗談から始まり、いろいろと調べた結果「ライブラリを使用しよう」という結論に至りました。しかし、実際のJavaScriptでタイムゾーン関連の機能をどの程度サポートし、どのような問題点があるかを知らないまま、むやみに外部ライブラリに依存するのは決して望ましいアプローチではありません。常にそうであるように、それぞれの状況に応じた適切なツールを選択することが重要です。

参考リンク

TOAST Meetup 編集部

TOASTの技術ナレッジやお得なイベント情報を発信していきます
pagetop