連載
» 2018年09月05日 05時00分 公開

.NET TIPS:サマータイムを処理するには?[.NET 3.5、C#/VB]

.NETでサマータイムを扱うにはDateTimeOffset構造体を利用するが、その際に知っておくべきこと、日時の取得/生成などを行う方法を説明する。

[山本康彦,BluewaterSoft/Microsoft MVP for Windows Development]
「.NET TIPS」のインデックス

連載「.NET TIPS」

 サマータイム(夏時間)は英語ではDaylight Saving Time(DST)といい、欧米を中心として使われている制度である。DSTに対応したプログラミングは、海外向け、あるいは、海外と連携するアプリやWebサービスなどの開発者にはおなじみであろうが、初めて対応する開発者には多くの困難が待ち受けている。本稿では、その主なポイントを解説する。

POINT DSTに対応した日時の計算方法

DSTに対応した日時の計算方法まとめ DSTに対応した日時の計算方法まとめ


 特定のトピックをすぐに知りたいという方は以下のリンクを活用してほしい。

 なお、本稿に掲載したサンプルコードをそのまま試すにはVisual Studio 2017以降が必要である。特記なきサンプルコードはコンソールアプリの一部であり、コードの冒頭に以下の宣言が必要となる。

using System;
using static System.Console;

Imports System.Console

本稿のサンプルコードに必要な宣言(上:C#、下:VB)

準備:太平洋標準時を設定する

 現在の日本ではDSTを実施していないため*1、本稿ではDSTの実施例として太平洋標準時(PST)を用いる。

 以降の解説やサンプルコードの出力を確認するには、WindowsのタイムゾーンをPSTに変更しておいていただきたい。次の画像にWindows 10での設定例を示す。

WindowsのタイムゾーンをPSTに設定する(Windows 10) WindowsのタイムゾーンをPSTに設定する(Windows 10)
[タイムゾーンを自動的に設定する]をオフにし、[タイムゾーン]ドロップダウンで太平洋標準時(PST)を選択する。[夏時間に合わせて自動的に調整する]はオンにしておく。
また、日付と時刻を手動で設定できるように、[時刻を自動的に設定する]はオフにしておく。必要に応じて[変更]ボタンを使ってWindowsの日時を設定する。
これらの変更は、本稿の内容を確認し終わったら元に戻すのを忘れずに。

*1 日本でも数年間ではあるが、かつてサマータイムが実施されたことがある。手元のWindows 10を確認したところ、レジストリにその情報は登録されていなかった。UNIX系のOSが利用するtz databaseには登録されている。

  1. サマータイム - 環境省(PDF、p.2に1948年から実施されたサマータイムの記載)
  2. Microsoft Windows オペレーティング システム用の夏時間を構成する方法(「詳細」−「Windows タイム ゾーンのレジストリ情報」)
  3. 日付と時刻の豆知識 (4)tz databaseと日本のサマータイム - hnwの日記 (id:hnw)

DSTの困難さとは?

 DSTを扱う困難さは、次の2点に起因する。

1. UTC(協定世界時)からのオフセットが変化する
2. DST切り替え日は1日が24時間ではない

 1.はよく知られているように、DSTでは時計を進めるというものだ。太平洋標準時(PST)では、標準のオフセットは-8時間であるが、DST期間中は-7時間になる。

 従って、現在日時を取得/保存するときに、オフセットも一緒に取得/保存することになる。.NET Framework 3.5以降では、DateTimeOffset構造体(System名前空間)を使って日時を扱えばよい。別法として、日時を常にUTCで保存してもよい。なお、オフセットからタイムゾーンは一意に定まらないので、タイムゾーンの情報も必要なら、いずれにしてもタイムゾーンを別に保存しておく必要がある。

 2.は、見落としがちだが、とても厄介な問題だ。DSTでオフセットを1時間ずらすとすると、DST開始日には1時間が消えてなくなり、1日が1時間短い23時間になる(次の図)。1日が24時間であることを前提にしているプログラムや、始業時刻までの時間が一定であることを前提にしているバッチ処理などは、見直しが必要だ。

 逆に、DST終了日には同じ1時間が繰り返され、1日が1時間長い25時間になる。1日の間に同じ時刻が2回登場するのである。時刻の順序に依存するプログラムや、時刻をユニークなIDとして使っているプログラムなどは見直しが必要だ。また、時刻の入力には工夫が必要になるし、時刻の出力や印刷にもどちらの時刻か(DST終了前か後か)が分かるような工夫が必要になる。

サマータイムの開始と終了 サマータイムの開始と終了
2018年の太平洋標準時(PST)の例。
DST開始日の3月11日(日)には、午前2時から3時までの1時間がなくなり、1日が23時間になる。
DST終了日の11月4日(日)には、午前1時から2時までの1時間が繰り返され、1日が25時間になる。時刻の前後関係が逆転することもある(例えば、DST終了前の1時45分から30分後はDST終了後の1時15分となる)。

DSTの情報を得るには?

 .NET Framework 3.5以降では、TimeZoneInfoクラス(System名前空間)がタイムゾーンの情報とともにDSTの情報も管理している。DSTの開始日/終了日は、◯月の第◯週の◯曜日という形で保持されている(欧米のサマータイムのルールを表現するのに便利な形式)。

 Windowsに設定されたタイムゾーンがPSTの場合、TimeZoneInfoクラスが保持している主な情報は次のコードのようになっている。

// Windowsに設定されているタイムゾーン情報
var currentTimeZone = TimeZoneInfo.Local;
WriteLine($"Id={currentTimeZone.Id}");
WriteLine($"DisplayName={currentTimeZone.DisplayName}");
WriteLine($"BaseUtcOffset={currentTimeZone.BaseUtcOffset.TotalHours:0.0}H");
WriteLine($"StandardName={currentTimeZone.StandardName}");
WriteLine($"SupportsDaylightSavingTime={currentTimeZone.SupportsDaylightSavingTime}");
WriteLine($"DaylightName={currentTimeZone.DaylightName}");
// 出力:
// Id=Pacific Standard Time
// DisplayName=(UTC-08:00) 太平洋標準時 (米国およびカナダ)
// BaseUtcOffset=-8.0H
// StandardName=太平洋標準時
// SupportsDaylightSavingTime=True
// DaylightName=太平洋夏時間

// DSTの調整ルール
TimeZoneInfo.AdjustmentRule[] rules = currentTimeZone.GetAdjustmentRules();
for(int i=0; i<rules.Length; i++)
{
  WriteLine();
  WriteLine($"AdjustmentRule[{i}]");
  WriteLine($"DateStart→DateEnd={rules[i].DateStart:yyyy/MM/dd}→{rules[i].DateEnd:yyyy/MM/dd}");
  WriteLine($"DaylightDelta={rules[i].DaylightDelta.TotalHours:0.0}H");
  TimeZoneInfo.TransitionTime s = rules[i].DaylightTransitionStart;
  WriteLine($"DaylightTransitionStart={s.Month}月 第{s.Week} {s.DayOfWeek} {s.TimeOfDay:HH:mm}");
  TimeZoneInfo.TransitionTime e = rules[i].DaylightTransitionEnd;
  WriteLine($"DaylightTransitionEnd={e.Month}月 第{e.Week} {e.DayOfWeek} {e.TimeOfDay:HH:mm}");
}
// 出力:
// AdjustmentRule[0]
// DateStart→DateEnd=0001/01/01→2006/12/31
// DaylightDelta=1.0H
// DaylightTransitionStart=4月 第1 Sunday 02:00
// DaylightTransitionEnd=10月 第5 Sunday 02:00
//
// AdjustmentRule[1]
// DateStart→DateEnd=2007/01/01→9999/12/31
// DaylightDelta=1.0H
// DaylightTransitionStart=3月 第2 Sunday 02:00
// DaylightTransitionEnd=11月 第1 Sunday 02:00

' Windowsに設定されているタイムゾーン情報
Dim currentTimeZone = TimeZoneInfo.Local
WriteLine($"Id={currentTimeZone.Id}")
WriteLine($"DisplayName={currentTimeZone.DisplayName}")
WriteLine($"BaseUtcOffset={currentTimeZone.BaseUtcOffset.TotalHours:0.0}H")
WriteLine($"StandardName={currentTimeZone.StandardName}")
WriteLine($"SupportsDaylightSavingTime={currentTimeZone.SupportsDaylightSavingTime}")
WriteLine($"DaylightName={currentTimeZone.DaylightName}")
' 出力:
' Id=Pacific Standard Time
' DisplayName=(UTC-08:00) 太平洋標準時 (米国およびカナダ)
' BaseUtcOffset=-8.0H
' StandardName=太平洋標準時
' SupportsDaylightSavingTime=True
' DaylightName=太平洋夏時間

' DSTの調整ルール
Dim rules As TimeZoneInfo.AdjustmentRule() = currentTimeZone.GetAdjustmentRules()
For i As Integer = 0 To (rules.Length - 1)
  WriteLine()
  WriteLine($"AdjustmentRule[{i}]")
  WriteLine($"DateStart→DateEnd={rules(i).DateStart:yyyy/MM/dd}→{rules(i).DateEnd:yyyy/MM/dd}")
  WriteLine($"DaylightDelta={rules(i).DaylightDelta.TotalHours:0.0}H")
  Dim s As TimeZoneInfo.TransitionTime = rules(i).DaylightTransitionStart
  WriteLine($"DaylightTransitionStart={s.Month}月 第{s.Week} {s.DayOfWeek} {s.TimeOfDay:HH:mm}")
  Dim e As TimeZoneInfo.TransitionTime = rules(i).DaylightTransitionEnd
  WriteLine($"DaylightTransitionEnd={e.Month}月 第{e.Week} {e.DayOfWeek} {e.TimeOfDay:HH:mm}")
Next
' 出力:
' AdjustmentRule[0]
' DateStart→DateEnd=0001/01/01→2006/12/31
' DaylightDelta=1.0H
' DaylightTransitionStart=4月 第1 Sunday 02:00
' DaylightTransitionEnd=10月 第5 Sunday 02:00
'
' AdjustmentRule[1]
' DateStart→DateEnd=2007/01/01→9999/12/31
' DaylightDelta=1.0H
' DaylightTransitionStart=3月 第2 Sunday 02:00
' DaylightTransitionEnd=11月 第1 Sunday 02:00

Windowsに設定されたタイムゾーン情報を表示する例(上:C#、下:VB)
Idプロパティ(この例では「Pacific Standard Time」という文字列)は、ローカル以外のタイムゾーンのTimeZoneInfoオブジェクトを得るのに使われる。
タイムゾーンのDisplayName/StandardName/DaylightNameは、ロケールによって得られる文字列が異なるので、注意してほしい。
DSTの調整ルールは、ある程度過去のものまで保持されている。PSTの場合、開始日/終了日のルールが2007年から変更になったので、それ以前と以降の2つの調整ルールがある。とある日時がDSTなのかそうでないのかを調べるには、まずその日付から適用されるルールを探し、次にその日時をDSTの開始日時/終了日時と比較することになる。実際には、そのような面倒な処理はTimeZoneInfoクラスのメソッドが行ってくれる。
この例(PST)の場合、DST開始日時は3月の第2日曜、すなわち2018年なら3月11日の午前2時だ。終了日時は、11月の第1日曜、すなわち2018年なら11月4日の午前2時である。また、DSTで時計を進める時間がDaylightDeltaプロパティに入っている。もしもDSTで2時間進めることがあれば、ここの値が2時間となる。
なお、Windowsが持っているタイムゾーンの一覧を得るには、TimeZoneInfoクラスのGetSystemTimeZonesメソッドを使う(「タイムゾーンから時差を求めるには?[C#、VB]」を参照)。

現在日時を取得・保存するには?

 .NET Framework 3.5以降では、DateTimeOffset構造体のNowプロパティで現在日時を取得すればよい(次のコード)。TimeZoneInfoオブジェクトが持っているDSTの情報を利用して、自動的に正しいオフセットを設定してくれる。

var now = DateTimeOffset.Now;
WriteLine(now);
// 出力例:
// DSTのときに取得した場合
// 2018/08/23 2:04:46 -07:00
// DSTではないときに取得した場合
// 2018/11/23 2:06:05 -08:00

Dim Now = DateTimeOffset.Now
WriteLine(Now)
' 出力例:
' DSTときに取得した例
' 2018/08/23 2:04:46 -07:00
' DSTではないときに取得した例
' 2018/11/23 2:06:05 -08:00

現在日時を取得する例(上:C#、下:VB)
Windowsのタイムゾーン設定がPSTの場合。
DST期間中に現在日時を取得すると、オフセットが-7時間になる(標準時より1時間進み)。DSTではない期間に取得すると、オフセットは-8時間になる。

 取得した現在日時を保存するには、DateTimeOffsetオブジェクトをそのまま、あるいは文字列にシリアライズして保存するのが簡単だ。あるいは、UTCの日時に変換してから保存してもよいだろう。なお、DateTimeOffsetオブジェクトにタイムゾーンの情報は入っていないので、タイムゾーンの情報(例えば、タイムゾーンのID)も保存する必要があるときは、日時とは別に保存することになる。

DSTとタイムゾーンの違いは?

 タイムゾーンの違い(例えば日本標準時とPST)もDSTも、UTCからのオフセットが変化する。タイムゾーンを移動すれば、DSTの開始/終了と同じように時刻が飛んだり巻き戻ったりする。

 両者はよく似ているが、.NET Frameworkでの扱いには違いがある。

  • DSTはTimeZoneInfoオブジェクトが管理しており、DateTimeOffset.Nowのオフセットは自動的に調整される
  • タイムゾーンごとにTimeZoneInfoオブジェクトは異なる。アプリの実行中にWindowsのタイムゾーン設定が変えられても、TimeZoneInfoオブジェクトは変化しない

 アプリの実行中にタイムゾーンが変更されてもTimeZoneInfoオブジェクトは変化しないので、DateTimeOffset.Nowのオフセットも調整されない。アプリで明示的にTimeZoneInfoオブジェクトを再生成する必要がある(次のコード)。

Microsoft.Win32.SystemEvents.TimeChanged += (s, e) 
  => TimeZoneInfo.ClearCachedData();

AddHandler Microsoft.Win32.SystemEvents.TimeChanged,
  Sub(s, e)
    TimeZoneInfo.ClearCachedData()
  End Sub

タイムゾーンが変更されたときにTimeZoneInfoオブジェクトを再生成する(上:C#、下:VB)
これはWPFアプリの例である。ウィンドウのコンストラクタの末尾に追加する。
Windowsのタイムゾーンが変わったとき(エンドユーザーの操作によって、あるいは、位置情報による自動調整によって)、TimeChangedイベントが発生する。そのイベントハンドラーでTimeZoneInfoクラスのClearCachedDataメソッドを呼び出すと、新しいTimeZoneInfoオブジェクトが作られてTimeZoneInfo.Localにセットされる。これにより、DateTimeOffset.Nowのオフセットも新しいタイムゾーンのものになる。
DSTの開始/終了時には、この処理は必要ない。DSTはTimeZoneInfoオブジェクトの内部で管理されているからだ。

任意の日時を生成するには?

 任意のDateTimeOffsetオブジェクトを生成するとき、DSTがある場合は何時間のオフセットを設定するかが問題になる。PSTの場合は、-7時間とするか-8時間とするかを決めねばならない。TimeZoneInfoオブジェクトのGetUtcOffsetメソッドを使うと、ローカル時刻からその時刻に対応したオフセットを求められる。

 ローカル時刻のDateTimeオブジェクト(System名前空間)を与えてDateTimeOffsetオブジェクトを生成するメソッドの例は、次のコードのようになる(ただし、後述するようにこれでは問題がある)。

static DateTimeOffset CreateDtoA(DateTime localDateTime)
{
  TimeSpan offset = TimeZoneInfo.Local.GetUtcOffset(localDateTime);
  return new DateTimeOffset(localDateTime, offset);
}

Function CreateDtoA(localDateTime As DateTime) As DateTimeOffset
  Dim offset As TimeSpan = TimeZoneInfo.Local.GetUtcOffset(localDateTime)
  Return New DateTimeOffset(localDateTime, offset)
End Function

ローカルDateTimeからDateTimeOffsetを生成するメソッド[不完全](上:C#、下:VB)

 ただし、上のメソッドでは、DST開始時の「失われた」時間を指定したときに、誤ったオフセットになってしまう(後掲の実行例を参照)。それに対処するには、DateTimeOffsetオブジェクトをTimeZoneInfoクラスのConvertTimeメソッドに渡して、正しいオフセットに補正する(次のコード)。

static DateTimeOffset CreateDtoB(DateTime localDateTime)
{
  TimeSpan offset = TimeZoneInfo.Local.GetUtcOffset(localDateTime);
  DateTimeOffset dto = new DateTimeOffset(localDateTime, offset);
  return TimeZoneInfo.ConvertTime(dto, TimeZoneInfo.Local);
}

Function CreateDtoB(localDateTime As DateTime) As DateTimeOffset
  Dim offset As TimeSpan = TimeZoneInfo.Local.GetUtcOffset(localDateTime)
  Dim dto As DateTimeOffset = New DateTimeOffset(localDateTime, offset)
  Return TimeZoneInfo.ConvertTime(dto, TimeZoneInfo.Local)
End Function

ローカルDateTimeからDateTimeOffsetを生成するメソッド(上:C#、下:VB)
DST開始時の「失われた」時間が指定されたときに誤ったオフセットになる(PSTの場合、DST期間内なのに標準時の-8時間になる)のを避けるため、TimeZoneInfoクラスのConvertTimeメソッドでオフセットを補正する。

 タイムゾーンがPSTのときに上記CreateDtoAメソッドを呼び出す例を、次のコードに示す。DST開始時以外はCreateDtoBメソッドの結果も同じになる。次のコードには、DST切り替え時のみCreateDtoBメソッドの呼び出しも載せてある。この実行例を見てもらうと、DST終了時は(2つある同一時刻のうち)1つ目の時刻を生成できていないことが分かる。その時間帯の時刻を生成するには、UTCでDateTimeOffsetオブジェクトを作ってから変換する、あるいは、午前0時のDateTimeOffsetオブジェクトを作ってから時刻を加算するといった工夫が必要になる。

// 標準時の例
var dt1 = new DateTime(2018, 1, 1, 7, 0, 0);
var dto1 = CreateDtoA(dt1);
WriteLine($"{dt1} → {dto1} (UTC {dto1.UtcDateTime:HH:mm:ss})");
// 出力:
// 2018/01/01 7:00:00 → 2018/01/01 7:00:00 -08:00 (UTC 15:00:00)

// DSTの例
var dt2 = new DateTime(2018, 7, 1, 7, 0, 0);
var dto2 = CreateDtoA(dt2);
WriteLine($"{dt2} → {dto2} (UTC {dto2.UtcDateTime:HH:mm:ss})");
// 出力:
// 2018/07/01 7:00:00 → 2018/07/01 7:00:00 -07:00 (UTC 14:00:00)

// DST開始をまたぐ例
var dt3 = new DateTime(2018, 3, 11, 1, 30, 0);
var dto3 = CreateDtoA(dt3);
WriteLine($"{dt3} → {dto3} (UTC {dto3.UtcDateTime:HH:mm:ss})");

var dt4 = new DateTime(2018, 3, 11, 2, 30, 0);
var dto4A = CreateDtoA(dt4);
WriteLine($"{dt4} → {dto4A} (UTC {dto4A.UtcDateTime:HH:mm:ss}) [A]");
var dto4B = CreateDtoB(dt4);
WriteLine($"{dt4} → {dto4B} (UTC {dto4B.UtcDateTime:HH:mm:ss}) [B]");

var dt5 = new DateTime(2018, 3, 11, 3, 30, 0);
var dto5 = CreateDtoA(dt5);
WriteLine($"{dt5} → {dto5} (UTC {dto5.UtcDateTime:HH:mm:ss})");

// 出力:
// 2018/03/11 1:30:00 → 2018/03/11 1:30:00 -08:00 (UTC 09:30:00)
// 2018/03/11 2:30:00 → 2018/03/11 2:30:00 -08:00 (UTC 10:30:00) [A]
// 上の結果は誤り。2:00を過ぎているのだからオフセットは-7時間のはずである
// 2018/03/11 2:30:00 → 2018/03/11 3:30:00 -07:00 (UTC 10:30:00) [B]
// 2018/03/11 3:30:00 → 2018/03/11 3:30:00 -07:00 (UTC 10:30:00)

// DST終了をまたぐ例
var dt6 = new DateTime(2018, 11, 4, 0, 30, 0);
var dto6 = CreateDtoA(dt6);
WriteLine($"{dt6} → {dto6} (UTC {dto6.UtcDateTime:HH:mm:ss})");

var dt7 = new DateTime(2018, 11, 4, 1, 30, 0);
var dto7A = CreateDtoA(dt7);
WriteLine($"{dt7} → {dto7A} (UTC {dto7A.UtcDateTime:HH:mm:ss}) [A]");
var dto7B = CreateDtoB(dt7);
WriteLine($"{dt7} → {dto7B} (UTC {dto7A.UtcDateTime:HH:mm:ss}) [B]");

var dt8 = new DateTime(2018, 11, 4, 2, 30, 0);
var dto8 = CreateDtoA(dt8);
WriteLine($"{dt8} → {dto8} (UTC {dto8.UtcDateTime:HH:mm:ss})");

// 出力:
// 2018/11/04 0:30:00 → 2018/11/04 0:30:00 -07:00 (UTC 07:30:00)
// 2018/11/04 1:30:00 → 2018/11/04 1:30:00 -08:00 (UTC 09:30:00) [A]
// 2018/11/04 1:30:00 → 2018/11/04 1:30:00 -08:00 (UTC 09:30:00) [B]
// 2018/11/04 2:30:00 → 2018/11/04 2:30:00 -08:00 (UTC 10:30:00)

' 標準時の例
Dim dt1 = New DateTime(2018, 1, 1, 7, 0, 0)
Dim dto1 = CreateDtoA(dt1)
WriteLine($"{dt1} → {dto1} (UTC {dto1.UtcDateTime:HH:mm:ss})")
' 出力:
' 2018/01/01 7:00:00 → 2018/01/01 7:00:00 -08:00 (UTC 15:00:00)

' DSTの例
Dim dt2 = New DateTime(2018, 7, 1, 7, 0, 0)
Dim dto2 = CreateDtoA(dt2)
WriteLine($"{dt2} → {dto2} (UTC {dto2.UtcDateTime:HH:mm:ss})")
' 出力:
' 2018/07/01 7:00:00 → 2018/07/01 7:00:00 -07:00 (UTC 14:00:00)

' DST開始をまたぐ例
Dim dt3 = New DateTime(2018, 3, 11, 1, 30, 0)
Dim dto3 = CreateDtoA(dt3)
WriteLine($"{dt3} → {dto3} (UTC {dto3.UtcDateTime:HH:mm:ss})")

Dim dt4 = New DateTime(2018, 3, 11, 2, 30, 0)
Dim dto4A = CreateDtoA(dt4)
WriteLine($"{dt4} → {dto4A} (UTC {dto4A.UtcDateTime:HH:mm:ss}) [A]")
Dim dto4B = CreateDtoB(dt4)
WriteLine($"{dt4} → {dto4B} (UTC {dto4B.UtcDateTime:HH:mm:ss}) [B]")

Dim dt5 = New DateTime(2018, 3, 11, 3, 30, 0)
Dim dto5 = CreateDtoA(dt5)
WriteLine($"{dt5} → {dto5} (UTC {dto5.UtcDateTime:HH:mm:ss})")

' 出力:
' 2018/03/11 1:30:00 → 2018/03/11 1:30:00 -08:00 (UTC 09:30:00)
' 2018/03/11 2:30:00 → 2018/03/11 2:30:00 -08:00 (UTC 10:30:00) [A]
' 上の結果は誤り。2:00を過ぎているのだからオフセットは-7時間のはずである
' 2018/03/11 2:30:00 → 2018/03/11 3:30:00 -07:00 (UTC 10:30:00) [B]
' 2018/03/11 3:30:00 → 2018/03/11 3:30:00 -07:00 (UTC 10:30:00)

' DST終了をまたぐ例
Dim dt6 = New DateTime(2018, 11, 4, 0, 30, 0)
Dim dto6 = CreateDtoA(dt6)
WriteLine($"{dt6} → {dto6} (UTC {dto6.UtcDateTime:HH:mm:ss})")

Dim dt7 = New DateTime(2018, 11, 4, 1, 30, 0)
Dim dto7A = CreateDtoA(dt7)
WriteLine($"{dt7} → {dto7A} (UTC {dto7A.UtcDateTime:HH:mm:ss}) [A]")
Dim dto7B = CreateDtoB(dt7)
WriteLine($"{dt7} → {dto7B} (UTC {dto7A.UtcDateTime:HH:mm:ss}) [B]")

Dim dt8 = New DateTime(2018, 11, 4, 2, 30, 0)
Dim dto8 = CreateDtoA(dt8)
WriteLine($"{dt8} → {dto8} (UTC {dto8.UtcDateTime:HH:mm:ss})")

' 出力:
' 2018/11/04 0:30:00 → 2018/11/04 0:30:00 -07:00 (UTC 07:30:00)
' 2018/11/04 1:30:00 → 2018/11/04 1:30:00 -08:00 (UTC 09:30:00) [A]
' 2018/11/04 1:30:00 → 2018/11/04 1:30:00 -08:00 (UTC 09:30:00) [B]
' 2018/11/04 2:30:00 → 2018/11/04 2:30:00 -08:00 (UTC 10:30:00)

ローカルDateTimeからDateTimeOffsetを生成するメソッドの実行例(上:C#、下:VB)
DST開始日のUTC 10:30は、-7時間のオフセット(=DSTでのオフセット)でなければならない。CreateDtoAメソッドの結果([A]表示)は誤っている。そこで正しい結果を得るためだけにCreateDtoBメソッドを使わねばならない([B]表示)。
DST終了日は、どちらのメソッドを使っても2回目の1:30(UTC 09:30)が生成され、1回目の1:30(=DST期間内)が生成できていない(UTC 8:30がない)。メソッドに与えるDateTime型は1日24時間までしかないのに、DST終了日は25時間あるのだから、表現できない時間が生じるということだ。

ある日時がDSTかどうかを知るには?

 TimeZoneInfoオブジェクトのIsDaylightSavingTimeメソッドを使う(次のコード)。

// 標準時の例
var dto1 = new DateTimeOffset(2018, 1, 1, 7, 0, 0, TimeSpan.FromHours(-8.0));
WriteLine($"{dto1}はDTSか?→{TimeZoneInfo.Local.IsDaylightSavingTime(dto1)}");
// 出力:2018/01/01 7:00:00 -08:00はDTSか?→False

// DSTの例
var dto2 = new DateTimeOffset(2018, 7, 1, 7, 0, 0, TimeSpan.FromHours(-7.0));
WriteLine($"{dto2}はDTSか?→{TimeZoneInfo.Local.IsDaylightSavingTime(dto2)}");
// 出力:2018/07/01 7:00:00 -07:00はDTSか?→True

var dto3 = new DateTimeOffset(2018, 7, 1, 0, 0, 0, TimeSpan.Zero);
WriteLine($"{dto3}はDTSか?→{TimeZoneInfo.Local.IsDaylightSavingTime(dto3)}");
// 出力:2018/07/01 0:00:00 +00:00はDTSか?→True

' 標準時の例
Dim dto1 = New DateTimeOffset(2018, 1, 1, 7, 0, 0, TimeSpan.FromHours(-8.0))
WriteLine($"{dto1}はDTSか?→{TimeZoneInfo.Local.IsDaylightSavingTime(dto1)}")
' 出力:2018/01/01 7:00:00 -08:00はDTSか?→False

' DSTの例
Dim dto2 = New DateTimeOffset(2018, 7, 1, 7, 0, 0, TimeSpan.FromHours(-7.0))
WriteLine($"{dto2}はDTSか?→{TimeZoneInfo.Local.IsDaylightSavingTime(dto2)}")
' 出力:2018/07/01 7:00:00 -07:00はDTSか?→True

Dim dto3 = New DateTimeOffset(2018, 7, 1, 0, 0, 0, TimeSpan.Zero)
WriteLine($"{dto3}はDTSか?→{TimeZoneInfo.Local.IsDaylightSavingTime(dto3)}")
' 出力:2018/07/01 0:00:00 +00:00はDTSか?→True

ある日時がDST期間内かどうかを求める例(上:C#、下:VB)
最後の例から、DateTimeOffsetオブジェクトのオフセットを見て判定しているのではないことが分かる。DateTimeOffsetオブジェクトの表す日時がTimeZoneInfoクラスの保持しているDSTの期間に含まれているかどうかを判定しているのだ。
サンプルコードということでオフセットをハードコーディングしているが、実際には「任意の日時を生成するには?」で説明したようにしてほしい。

2つの日時から経過時間を計算するには?

 単純にDateTimeOffsetオブジェクト同士の引き算を行えばよい(次のコード)。オフセットの違いは適切に調整してくれる。

// 標準時間同士
var dto1 = new DateTimeOffset(2018, 2, 28, 7, 0, 0, TimeSpan.FromHours(-8.0));
var dto2 = new DateTimeOffset(2018, 3, 1, 7, 0, 0, TimeSpan.FromHours(-8.0));
TimeSpan ts1 = dto2 - dto1;
WriteLine($"({dto2})−({dto1})={ts1.TotalHours:0.0}H");
// 出力:(2018/03/01 7:00:00 -08:00)−(2018/02/28 7:00:00 -08:00)=24.0H

// DST同士
var dto3 = new DateTimeOffset(2018, 7, 31, 7, 0, 0, TimeSpan.FromHours(-7.0));
var dto4 = new DateTimeOffset(2018, 8, 1, 7, 0, 0, TimeSpan.FromHours(-7.0));
TimeSpan ts2 = dto4 - dto3;
WriteLine($"({dto4})−({dto3})={ts2.TotalHours:0.0}H");
// 出力:(2018/08/01 7:00:00 -07:00)−(2018/07/31 7:00:00 -07:00)=24.0H

// DST開始時刻またぎ
var dto5 = new DateTimeOffset(2018, 3, 10, 7, 0, 0, TimeSpan.FromHours(-8.0));
var dto6 = new DateTimeOffset(2018, 3, 11, 7, 0, 0, TimeSpan.FromHours(-7.0));
TimeSpan ts3 = dto6 - dto5;
WriteLine($"({dto6})−({dto5})={ts3.TotalHours:0.0}H");
// 出力:(2018/03/11 7:00:00 -07:00)−(2018/03/10 7:00:00 -08:00)=23.0H

// DST終了時刻またぎ
var dto7 = new DateTimeOffset(2018, 11, 3, 7, 0, 0, TimeSpan.FromHours(-7.0));
var dto8 = new DateTimeOffset(2018, 11, 4, 7, 0, 0, TimeSpan.FromHours(-8.0));
TimeSpan ts4 = dto8 - dto7;
WriteLine($"({dto8})−({dto7})={ts4.TotalHours:0.0}H");
// 出力:(2018/11/04 7:00:00 -08:00)−(2018/11/03 7:00:00 -07:00)=25.0H

' 標準時間同士
Dim dto1 = New DateTimeOffset(2018, 2, 28, 7, 0, 0, TimeSpan.FromHours(-8.0))
Dim dto2 = New DateTimeOffset(2018, 3, 1, 7, 0, 0, TimeSpan.FromHours(-8.0))
Dim ts1 As TimeSpan = dto2 - dto1
WriteLine($"({dto2})−({dto1})={ts1.TotalHours:0.0}H")
' 出力:(2018/03/01 7:00:00 -08:00)−(2018/02/28 7:00:00 -08:00)=24.0H

' DST同士
Dim dto3 = New DateTimeOffset(2018, 7, 31, 7, 0, 0, TimeSpan.FromHours(-7.0))
Dim dto4 = New DateTimeOffset(2018, 8, 1, 7, 0, 0, TimeSpan.FromHours(-7.0))
Dim ts2 As TimeSpan = dto4 - dto3
WriteLine($"({dto4})−({dto3})={ts2.TotalHours:0.0}H")
' 出力:(2018/08/01 7:00:00 -07:00)−(2018/07/31 7:00:00 -07:00)=24.0H

' DST開始時刻またぎ
Dim dto5 = New DateTimeOffset(2018, 3, 10, 7, 0, 0, TimeSpan.FromHours(-8.0))
Dim dto6 = New DateTimeOffset(2018, 3, 11, 7, 0, 0, TimeSpan.FromHours(-7.0))
Dim ts3 As TimeSpan = dto6 - dto5
WriteLine($"({dto6})−({dto5})={ts3.TotalHours:0.0}H")
' 出力:(2018/03/11 7:00:00 -07:00)−(2018/03/10 7:00:00 -08:00)=23.0H

' DST終了時刻またぎ
Dim dto7 = New DateTimeOffset(2018, 11, 3, 7, 0, 0, TimeSpan.FromHours(-7.0))
Dim dto8 = New DateTimeOffset(2018, 11, 4, 7, 0, 0, TimeSpan.FromHours(-8.0))
Dim ts4 As TimeSpan = dto8 - dto7
WriteLine($"({dto8})−({dto7})={ts4.TotalHours:0.0}H")
' 出力:(2018/11/04 7:00:00 -08:00)−(2018/11/03 7:00:00 -07:00)=25.0H

DateTimeOffsetを引き算して経過時間を求める例(上:C#、下:VB)
朝7時と翌日の朝7時との間の経過時間を求めている。通常は24時間だ。
DST開始時刻をまたいだ場合は23時間に、同じく終了時刻をまたいだ場合は25時間になることに注意。
サンプルコードということでオフセットをハードコーディングしているが、実際には「任意の日時を生成するには?」で説明したようにしてほしい。

日時に時間を加算するには?

 DateTimeOffsetオブジェクトにTimeSpanオブジェクトを加算すれば、正しい時刻が得られる。ただし、加算したことでDSTの開始時刻/終了時刻をまたぐと、時刻としては正しくてもオフセットが正しくなくなってしまう。そこで、「任意の日時を生成するには?」で説明したように、TimeZoneInfoクラスのConvertTimeメソッドでオフセットを補正する必要がある(次のコード)。

// DST開始時刻をまたぐ例
var dto1 = new DateTimeOffset(2018, 3, 10, 7, 0, 0, TimeSpan.FromHours(-8.0));
var dto2 = dto1 + TimeSpan.FromHours(24.0);
WriteLine($"({dto1})+24.0H=({dto2}) ←誤り");
// 出力:(2018/03/10 7:00:00 -08:00)+24.0H=(2018/03/11 7:00:00 -08:00) ←誤り

// 次のようにして常に正しいオフセットになるようにする
var dto3 = TimeZoneInfo.ConvertTime(dto2, TimeZoneInfo.Local);
WriteLine($"({dto1})+24.0H=({dto3}) ←正しい");
// 出力:(2018/03/10 7:00:00 -08:00)+24.0H=(2018/03/11 8:00:00 -07:00) ←正しい

// DST終了時刻をまたぐ例
var dto4 = new DateTimeOffset(2018, 11, 3, 7, 0, 0, TimeSpan.FromHours(-7.0));
var dto5 = TimeZoneInfo.ConvertTime(dto4 + TimeSpan.FromHours(24.0), TimeZoneInfo.Local);
WriteLine($"({dto4})+24.0H=({dto5})");
// 出力:(2018/11/03 7:00:00 -07:00)+24.0H=(2018/11/04 6:00:00 -08:00)

' DST開始時刻をまたぐ例
Dim dto1 = New DateTimeOffset(2018, 3, 10, 7, 0, 0, TimeSpan.FromHours(-8.0))
Dim dto2 = dto1 + TimeSpan.FromHours(24.0)
WriteLine($"({dto1})+24.0H=({dto2}) ←誤り")
' 出力:(2018/03/10 7:00:00 -08:00)+24.0H=(2018/03/11 7:00:00 -08:00) ←誤り

' 次のようにして常に正しいオフセットになるようにする
Dim dto3 = TimeZoneInfo.ConvertTime(dto2, TimeZoneInfo.Local)
WriteLine($"({dto1})+24.0H=({dto3}) ←正しい")
' 出力:(2018/03/10 7:00:00 -08:00)+24.0H=(2018/03/11 8:00:00 -07:00) ←正しい

' DST終了時刻をまたぐ例
Dim dto4 = New DateTimeOffset(2018, 11, 3, 7, 0, 0, TimeSpan.FromHours(-7.0))
Dim dto5 = TimeZoneInfo.ConvertTime(dto4 + TimeSpan.FromHours(24.0), TimeZoneInfo.Local)
WriteLine($"({dto4})+24.0H=({dto5})")
' 出力:(2018/11/03 7:00:00 -07:00)+24.0H=(2018/11/04 6:00:00 -08:00)

DateTimeOffsetにTimeSpanを足して時刻を求める例(上:C#、下:VB)
DateTimeOffsetオブジェクトにTimeSpanオブジェクトを加算/減算すると、DSTの開始時刻/終了時刻をまたいでしまう可能性がある。計算結果は、常にTimeZoneInfoクラスのConvertTimeメソッドを使ってオフセットを補正する。

まとめ

 サマータイム(DST)を扱う仕組みとして、.NET Framework 3.5からはDateTimeOffset構造体とTimeZoneInfoクラスが提供されている。DSTの開始時刻/終了時刻をまたぐ処理は、時刻が飛んだり巻き戻ったり、あるいは、1日の長さが24時間ではなくなったりするので、業務設計もプログラミングも注意が必要だ。また、本稿では扱わなかったが、終了日に2回現れる同じ時刻を区別してエンドユーザーに指定してもらうには、専用のUIを作る必要があるだろう。

利用可能バージョン:.NET Framework 3.5以降
カテゴリ:クラスライブラリ 処理対象:日付と時刻
使用ライブラリ:DateTimeOffset構造体(System名前空間)
使用ライブラリ:TimeZoneInfoクラス(System名前空間)
関連TIPS:タイムゾーンから時差を求めるには?[C#、VB]
関連TIPS:DateTimeとDateTimeOffsetの違いとは?[C#、VB]
関連TIPS:日時や時間間隔の加減算を行うには?
関連TIPS:日付や時刻を文字列に変換するには?
関連TIPS:日付や時刻の文字列をDateTimeオブジェクトに変換するには?
関連TIPS:構文:クラス名を書かずに静的メソッドを呼び出すには?[C# 6.0]
関連TIPS:VB.NETでクラス名を省略してメソッドや定数を利用するには?
関連TIPS:数値を右詰めや0埋めで文字列化するには?[C#、VB]


「.NET TIPS」のインデックス

.NET TIPS

Copyright© Digital Advantage Corp. All Rights Reserved.

RSSについて

アイティメディアIDについて

メールマガジン登録

@ITのメールマガジンは、 もちろん、すべて無料です。ぜひメールマガジンをご購読ください。