UnityのDateTimeOffsetでArgumentExceptionがでてしまう件

# はじめに

この記事はUnity5.4.3p3(Mono)で発生した以下のエラーの原因と対処についての記事です。
`System.ArgumentException: dateTime.Kind equals Local and offset does not equal the offset of the system's local time zone.`
また、文中で出てくるMonoの実装とは、おそらくこう実装されているだろうという参考コードです。

# DateTimeOffsetとは

[System.DateTimeOffset](https://msdn.microsoft.com/ja-jp/library/system.datetimeoffset(v=vs.110).aspx)
[System.DateTime](https://msdn.microsoft.com/ja-jp/library/system.datetime(v=vs.110).aspx)に[協定世界時(UTC)](https://ja.wikipedia.org/wiki/%E5%8D%94%E5%AE%9A%E4%B8%96%E7%95%8C%E6%99%82)からのオフセット([System.TimeSpan](https://msdn.microsoft.com/ja-jp/library/system.timespan(v=vs.110).aspx))を加味することで
異なる[タイムゾーン](https://ja.wikipedia.org/wiki/%E6%99%82%E9%96%93%E5%B8%AF_(%E6%A8%99%E6%BA%96%E6%99%82))の時刻でも計算できるようにするものです。

# 問題の状況

今回の問題は以下のコードで比較的簡単に発生します。

```csharp
DateTimeOffset.Parse("2017/03/11 12:00:00").AddDays(1)
```

ただし、これにはコード以外の前提条件が必要です。

- UnityにバンドルされているMono(mscorlib.dll)で動いている
  - どうもEditorではシステムのmscorlib.dllが参照されているようで再現しませんでした
- 端末の時間が[サマータイム](https://ja.wikipedia.org/wiki/%E5%A4%8F%E6%99%82%E9%96%93)が有効なタイムゾーンに設定されている
  - 例えばアメリカ([EST](https://ja.wikipedia.org/wiki/%E6%9D%B1%E9%83%A8%E6%A8%99%E6%BA%96%E6%99%82))など
      - ESTにおける2017年のサマータイムは3月12日(日)に始まり、11月5日(日)に終了します
- サマータイムが考慮されている端末である
  - iOSや一部のAndroid端末でのみ発生を確認しています

# 発生している内容

`DateTimeOffset`のコンストラクタに渡された`DateTime`の`DateTime.Kind`が[`DateTimeKind.Local`](https://msdn.microsoft.com/ja-jp/library/shx7s921(v=vs.110).aspx)である場合、
UTCからのオフセットの時間が[TimeZone.CurrentTimeZone](https://msdn.microsoft.com/ja-jp/library/system.timezone.currenttimezone(v=vs.110).aspx)と異なる場合に発生するエラーです。

UnityのMonoでは以下のように実装されていると思われます。

```csharp
if (dateTime.Kind == DateTimeKind.Local && offset != TimeZone.CurrentTimeZone.GetUtcOffset(dateTime))
{
	throw new ArgumentException("dateTime.Kind equals Local and offset does not equal the offset of the system's local time zone.");
}
```

前述のコードで発生しているロジックとしては以下の通りです。

- `DateTimeOffset.Parse("2017/03/11 12:00:00")`
  - `DateTimeOffset`が内部で保持している`DateTime`の`DateTimeKind`は`Local`に設定されている
  - オフセットは`TimeZone.CurrentTimeZone.GetUtcOffset(dateTime);`であるため **`-5`が設定される**
- AddDays(1) (内部で`Add`が呼ばれている)
  - `DateTime.Add(TimeSpan)`の結果(`"2017/03/12 12:00:00"`)、**サマータイムではない時間**から**サマータイムの時間**に切り替わる
  - `"2017/03/12 12:00:00"を指すDateTime`と**内部で保持しているオフセット(`-5`)がコンストラクタに渡される**
  - **"2017/03/12 12:00:00"の時点でのオフセットは`-4`**であるため、 **渡されたオフセット(`-5`)と相違**し、例外が発生する

UnityのMonoでは以下のように実装されていると思われます。

```csharp
public DateTimeOffset Add(TimeSpan timeSpan)
{
	return new DateTimeOffset(this.dt.Add(timeSpan), this.utc_offset);
}
```

つまり、サマータイムの境を超えるような計算をした場合に発生するということです。
もちろん、サマータイム中からサマータイムではない時間への変換も同様です。

# 類似の発生条件

結局のところ、`TimeZone.CurrentTimeZone.GetUtcOffset(dateTime)`と違うオフセットを渡すとエラーになるため、以下のようなコードでも簡単に発生する。

```C#
DateTimeOffset.Now.ToOffset(TimeSpan.FromHours(2.5)) // UTC+2.5のタイムゾーンは存在しないはず・・・
```

これはつまるところ次のコードでエラーが発生することを意味する

```C#
// 日本時間(JST)に変換する
DateTimeOffset.Now.ToOffset(TimeSpan.FromHours(9)) // JST以外に設定されている場合にエラー
```

また、端末の設定によりタイムゾーンが自動で切り替わった場合にも同様に例外が発生するのではないかと思います(未検証)

```C#
var dto = DateTimeOffset.Now;
...
// 端末のタイムゾーンが切り替わる
...
dto + TimeSpan.FromHours(1);
```

# 回避方法

DateTimeOffsetが内部で保持しているDateTimeのDateTimeKindがLocalの場合にオフセットのチェックがされるため、
**`DateTimeKind`を`DateTimeKind.Unspecified`にしてやれば問題は発生しません**。
`DateTimeKind`を使用しなくてもOffsetがあるため、挙動に影響はありません。

例えば次のようなコードを使用することで回避できます。

```C#
var local = System.DateTime.Now;
var offset = TimeZone.CurrentTimeZone.GetUtcOffset(local);
var unspecified = DateTime.SpecifyKind(local, DateTimeKind.Unspecified);
return new System.DateTimeOffset(unspecified, offset);
```

# Unity以外では？

コンストラクタでのチェックは依然としてされているものの、
計算の直前で`DateTimeKind.Unspecified`に変換されているので通常の利用時は問題は発生しません。
https://referencesource.microsoft.com/#mscorlib/system/datetimeoffset.cs,5cff8f26f5c7c095

**Unity以外のMonoについては未検証です。**