C#で日時データをタイムゾーンをまたいで正しく変換する方法

# 概要

C#に2種類ある日時の型（`DateTime`型と`DateTimeOffset`型）の違い、および`TimeZoneInfo`を使った日時の正しい変換方法について書きます。


# `DateTime`型と`DateTimeOffset`型について

C#には`DateTime`型と`DateTimeOffset`型という2つの型があります。
前者は日時を表現するための型、後者は『日時＋協定世界時（UTC）からのオフセット値』を表現するための型です。

`DateTime`型は『2016年8月29日 15時30分11秒』というところまでしか表現できない[^1]ので、それが日本時間なのかアメリカ時間なのかによって、そのデータが指す時刻の解釈に揺らぎが出てきます。
*※正確にはDateTimeインスタンスが指している時刻が、UTC時刻か、ローカル時刻か、それ以外の時刻か、のいずれかを指し示すことはできます。詳細は「捕捉: `DateTime`型の`Kind`プロパティーについて」を参照下さい。*

対して、`DateTimeOffset`型では『2016年8月29日 15時30分11秒。なお、この時刻は協定世界時から+9時間された時刻』という一意に特定出来るところまで表現できるので、`DateTimeOffset`型で表現された日時は必ずある一点を指し、解釈は揺らぎません。

なので、タイムゾーンを気にするようなシステムを構築するのであれば、基本的に`DateTimeOffset`型を使うことになります。[^2]
また、データベースに日時データを保存するときにも`DateTimeOffset`型で保存しておくと日時の解釈に揺らぎが出ないので後から困りません。


# タイムゾーンをまたいで正しく変換するには

`DateTimeOffset`と`TimeZoneInfo`を組み合わせて変換するのが一番楽です。
`TimeZoneInfo`は、あるタイムゾーンが世界協定時に対してどれぐらいオフセットがあるのかや、夏時間の有無やその変換ルールなど、タイムゾーンをまたいだ日時変換に必要となるデータを持っています。

# 実際のコード

コードの前半部分では、`DateTimeOffset`と`TimeZoneInfo`を組み合わせてタイムゾーンをまたいだ日時変換をしています。

コードの後半では、`DateTime`型を使ったときに陥りがちな残念なこと（夏時間や冬時間の扱いが上手くいかずに日時表現がおかしくなる）の例を4つ並べました。

```csharp
using System;

namespace DateTimeOffsetStudy
{
    class Program
    {
        static void Main(string[] args)
        {
            try
            {
                //日本時間のタイムゾーン情報
                var jstTimeZoneInfo = TimeZoneInfo.FindSystemTimeZoneById("Tokyo Standard Time");
                //米国東海岸時間のタイムゾーン情報
                var etTimeZoneInfo = TimeZoneInfo.FindSystemTimeZoneById("Eastern Standard Time");


                //////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
                // DateTimeOffsetでの変換 - 1（日本時間から夏時間な東海岸時間に）
                //////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////

                //2016年8月29日 15時30分11秒 (UTC+9) = いわゆる日本時間の2016年8月29日 15時30分11秒
                var jstDateTimeOffsetSample1 = new DateTimeOffset(2016, 8, 29, 15, 30, 11, jstTimeZoneInfo.BaseUtcOffset);

                // 東海岸時間に変換
                // jstDateTimeOffset1に入っている日時は、東海岸時間では夏時間にあたり13時間差なので
                // 2016年8月29日 2時30分11秒 (UTC-4)が返る
                //
                // 夏時間だろうが冬時間だろうが、TimeZoneInfo.ConvertTime()がよきに計らってくれる
                var etDateTimeOffsetSample1 = TimeZoneInfo.ConvertTime(jstDateTimeOffsetSample1, etTimeZoneInfo);

                Console.WriteLine($"日本時間の{jstDateTimeOffsetSample1}を東海岸時間に変換すると{etDateTimeOffsetSample1}です");
                // => 日本時間の8/29/2016 3:30:11 PM +09:00を東海岸時間に変換すると8/29/2016 2:30:11 AM -04:00です

                //DateTimeOffsetはある一意の地点を指しているので、変換したとしてもイコールである
                Console.WriteLine($"jstDateTimeOffsetSample1.Equals(etDateTimeOffsetSample1) = {jstDateTimeOffsetSample1.Equals(etDateTimeOffsetSample1)}");
                // => jstDateTimeOffsetSample1.Equals(etDateTimeOffsetSample1) = True


                //////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
                // DateTimeOffsetでの変換 - 2（日本時間から冬時間な東海岸時間に）
                //////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////

                //2016年1月29日 15時30分11秒 (UTC+9) = いわゆる日本時間の2016年1月29日 15時30分11秒
                var jstDateTimeOffsetSample2 = new DateTimeOffset(2016, 1, 29, 15, 30, 11, jstTimeZoneInfo.BaseUtcOffset);

                // 東海岸時間に変換
                // jstDateTimeOffset2に入っている日時は、東海岸時間では冬時間で14時間差なので
                // 2016年1月29日 1時30分11秒 (UTC-5)が返る
                //
                // 夏時間だろうが冬時間だろうが、TimeZoneInfo.ConvertTime()がよきに計らってくれる
                var etDateTimeOffset2 = TimeZoneInfo.ConvertTime(jstDateTimeOffsetSample2, etTimeZoneInfo);

                Console.WriteLine($"日本時間の{jstDateTimeOffsetSample2}を東海岸時間に変換すると{etDateTimeOffset2}です");
                // => 日本時間の1/29/2016 3:30:11 PM +09:00を東海岸時間に変換すると1/29/2016 1:30:11 AM -05:00です

                Console.WriteLine("");
                Console.WriteLine("=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=");



                //////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
                // DateTimeのよくある残念なパターン1 - 指定できない時刻
                //////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
                //
                // 『2016年3月13日 15時59分59秒』と『2015年11月1日 16時00分00秒』を日本時間として扱い、それぞれ東海岸時間にしようとする。
                // すると、それぞれ『2016年3月13日 1時59分59秒』と『2016年3月13日 3時00分00秒』に変換される。
                // なぜなら、東海岸時間で冬時間から夏時間に変わるときには1時59分59秒の次は3時00分00秒がくるので、
                // 前者は『冬時間の1時59分59秒』、後者は『夏時間の3時00分00秒』という変換のされ方になる。
                //
                var jstDateTime1 = new DateTime(2016, 3, 13, 15, 59, 59, DateTimeKind.Unspecified);
                var jstDateTime1Plus1Sec = jstDateTime1.AddSeconds(1);
                Console.WriteLine($"日本時間の{jstDateTime1}は東海岸時間の{TimeZoneInfo.ConvertTime(jstDateTime1, jstTimeZoneInfo, etTimeZoneInfo)}です");
                // => 日本時間の3/13/2016 3:59:59 PMは東海岸時間の3/13/2016 1:59:59 AMです
                Console.WriteLine($"日本時間の{jstDateTime1Plus1Sec}は東海岸時間の{TimeZoneInfo.ConvertTime(jstDateTime1Plus1Sec, jstTimeZoneInfo, etTimeZoneInfo)}です");
                // => 日本時間の3/13/2016 4:00:00 PMは東海岸時間の3/13/2016 3:00:00 AMです

                Console.WriteLine("↑↑↑おかしい↑↑↑");
                Console.WriteLine("");


                // DateTimeOffsetなら、時刻を正しく扱える
                var jstDateTimeOffset1 = new DateTimeOffset(2016, 3, 13, 15, 59, 59, jstTimeZoneInfo.BaseUtcOffset);
                var jstDateTimeOffset1Plus1Sec = jstDateTimeOffset1.AddSeconds(1);

                Console.WriteLine($"日本時間の{jstDateTimeOffset1}は東海岸時間の{TimeZoneInfo.ConvertTime(jstDateTimeOffset1, etTimeZoneInfo)}です");
                // => 日本時間の3/13/2016 3:59:59 PM +09:00は東海岸時間の3/13/2016 1:59:59 AM -05:00です
                Console.WriteLine($"日本時間の{jstDateTimeOffset1Plus1Sec}は東海岸時間の{TimeZoneInfo.ConvertTime(jstDateTimeOffset1Plus1Sec, etTimeZoneInfo)}です");
                // => 日本時間の3/13/2016 4:00:00 PM +09:00は東海岸時間の3/13/2016 3:00:00 AM -04:00です

                Console.WriteLine("↑↑↑DateTimeOffsetなら、オフセット部分が-5時間から-4時間に変化しているので問題無い↑↑↑");
                Console.WriteLine("");
                Console.WriteLine("=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=");


                //////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
                // DateTimeのよくある残念なパターン2 - ダブる時刻
                //////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
                //
                // 『2015年11月1日 14時59分59秒』と『2015年11月1日 15時00分00秒』を日本時間として扱い、それぞれ東海岸時間にしようとする。
                // すると、それぞれ『2015年11月1日 1時59分59秒』と『2015年11月1日 1時00分00秒』に変換される。
                // なぜなら、東海岸時間で夏時間から冬時間に変わるときには1時59分59秒の次にもう1回1時00分00秒がくるので、
                // 前者は『夏時間の1時59分59秒』、後者は『冬時間の1時00分00秒』という変換のされ方になる。
                //
                var jstDateTime2 = new DateTime(2015, 11, 1, 14, 59, 59, DateTimeKind.Unspecified);
                var jstDateTime2Plus1Sec = jstDateTime2.AddSeconds(1);
                Console.WriteLine($"日本時間の{jstDateTime2}は東海岸時間の{TimeZoneInfo.ConvertTime(jstDateTime2, jstTimeZoneInfo, etTimeZoneInfo)}です");
                // => 日本時間の11/1/2015 2:59:59 PMは東海岸時間の11/1/2015 1:59:59 AMです
                Console.WriteLine($"日本時間の{jstDateTime2Plus1Sec}は東海岸時間の{TimeZoneInfo.ConvertTime(jstDateTime2Plus1Sec, jstTimeZoneInfo, etTimeZoneInfo)}です");
                // => 日本時間の11/1/2015 3:00:00 PMは東海岸時間の11/1/2015 1:00:00 AMです

                Console.WriteLine("↑↑↑おかしい↑↑↑");
                Console.WriteLine("");

                // DateTimeOffsetなら、時刻を正しく扱える
                var jstDateTimeOffset2 = new DateTimeOffset(2015, 11, 1, 14, 59, 59, jstTimeZoneInfo.BaseUtcOffset);
                var jstDateTimeOffset2Plus1Sec = jstDateTimeOffset2.AddSeconds(1);

                Console.WriteLine($"日本時間の{jstDateTimeOffset2}は東海岸時間の{TimeZoneInfo.ConvertTime(jstDateTimeOffset2, etTimeZoneInfo)}です");
                // => 日本時間の11/1/2015 2:59:59 PM +09:00は東海岸時間の11/1/2015 1:59:59 AM -04:00です
                Console.WriteLine($"日本時間の{jstDateTimeOffset2Plus1Sec}は東海岸時間の{TimeZoneInfo.ConvertTime(jstDateTimeOffset2Plus1Sec, etTimeZoneInfo)}です");
                // => 日本時間の11/1/2015 3:00:00 PM +09:00は東海岸時間の11/1/2015 1:00:00 AM -05:00です

                Console.WriteLine("↑↑↑DateTimeOffsetなら、オフセット部分が-4時間から-5時間に変化しているので問題無い↑↑↑");
                Console.WriteLine("");
                Console.WriteLine("=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=");


                //////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
                // DateTimeのよくある残念なパターン3 - 変換しようとして例外が発生する時刻（パターン1の裏返し）
                //////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
                //
                // 『2016年3月13日 2時30分11秒』を東海岸時間として扱い、それを日本時間にしようとすると、ArgumentExceptionが発生する。
                // なぜなら、2016年3月13日は冬時間から夏時間に戻る日で、1時59分59秒の次は3時00分00秒なので、
                // 東海岸時間には『2016年3月13日 2時30分11秒』は存在しないから。
                //
                try
                {
                    Console.WriteLine($"{TimeZoneInfo.ConvertTime(new DateTime(2016, 3, 13, 2, 30, 11), etTimeZoneInfo, jstTimeZoneInfo)}");
                }
                catch (ArgumentException exception)
                {
                    Console.WriteLine($"例外が発生しました. {exception.Message}");
                }

                Console.WriteLine("");
                Console.WriteLine("=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=");


                //////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
                // DateTimeのよくある残念なパターン4 - 指定できない時刻（パターン2の裏返し）
                //////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
                //
                // 『2015年11月1日 0時59分59秒』と『2015年11月1日 1時00分00秒』を東海岸時間として扱い、それぞれ日本時間にしようとする。
                // すると、それぞれ『2015年11月1日 13時59分59秒』と『2015年11月1日 15時00分00秒』に変換される。
                // なぜなら、夏時間から冬時間に変わるときは1時59分59秒の次にもう1回1時0分0秒がくるので
                // UTCからのオフセット値がないと東海岸時間午前1時台は曖昧にしか表現できないため。
                //
                Console.WriteLine($"{TimeZoneInfo.ConvertTime(new DateTime(2015, 11, 1, 0, 59, 59), etTimeZoneInfo, jstTimeZoneInfo)}");
                Console.WriteLine($"{TimeZoneInfo.ConvertTime(new DateTime(2015, 11, 1, 1, 0, 0), etTimeZoneInfo, jstTimeZoneInfo)}");

                Console.WriteLine("");
                Console.WriteLine("=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=");

            }
            catch (TimeZoneNotFoundException)
            {
                Console.WriteLine("Unable to identify target time zone for conversion.");
            }

        }
    }
}

```

# 捕捉: `DateTime`型の`Kind`プロパティーについて

`DateTime`型には`Kind`プロパティーがあり、そのプロパティーで`DateTime`型のインスタンスが保持している時刻の意味合いを下表の通り持たせることができます。
なので、時刻系のデータを扱うときは、必ず時刻が一意に特定出来るUTCにして扱う（Kindが`DateTimeKind.Utc`になるようにする）というようにプログラムを組めば、`DateTime`型でも時刻変換に悩まされることは少なくなります。

| Kindの値 | 意味 |
|:-----------|-----------|
| `DateTimeKind.Utc` | UTCの時刻を保持している |
| `DateTimeKind.Local` | ローカル時刻を保持している |
| `DateTimeKind.Unspecified` | UTCやローカル時刻ではない時刻を保持している |

コメントで@yuba さんに指摘いただいたとおり、`DateTime`型だけで頑張るのであれば、具体的には以下のような点に気をつけてコードを書く形になります。

- `DateTime.Now`でなく`DateTime.UtcNow`で現在時刻を取る
- new でDateTimeインスタンス作るときには `DateTimeKind.Utc` を必ず指定する
- DBから取ってきた時刻値は変換する


# 備考
[^1]: 協定世界時からのオフセット値が持てないだけで、[`DateTime`型の分解能はミリ秒まであります](https://msdn.microsoft.com/ja-jp/library/system.datetime(v=vs.110).aspx)

[^2]: もちろん、データベースに保存するときには必ずUTCに変換した上で保存する、というようなことを強制すれば`DateTime`型でもよいのかもしれませんが。