C#(.NET Core 3.1, .NET 6)のランタイム上のシステムタイムゾーンを置き換えたい（TimeZoneInfo.LocalがUTCを返すようにしたい）



要約
--------

Linuxの場合は、環境変数`TZ=Etc/UTC`として実行するとUTC時間として実行できます。

Windowsの場合、環境変数では変更できません。システム日時をGMTなどUTC相当のタイムゾーンにするのが正攻法です。邪道な方法として`TimeZoneInfo.Local`で使う値をリフレクションを使って無理矢理UTC時間に置き換える方法があります。ただし、`TimeZoneInfo.Utc`をそのまま代入せず、`TimeZoneInfo.CreateCustomTimeZone`を使ってUTCタイムゾーンの複製を作ってから置き換えましょう。そうしないと、`TimeZoneInfo.ConvertTime`で任意のタイムゾーン値に変換する際に引数不正でエラーになってしまうことがあります。また、`TimeZoneInfo.ClearCachedData`を呼び出すとリフレクションで上書きしたものが元に戻ってしまいます。同様に`TimeZoneInfo.ConvertTime`にTicks==0なDateTimeを渡しても`TimeZoneInfo.ClearCachedData`が呼ばれるので呼んではいけません。

以上の通り、Windowsの方法についてはちょっと問題を起こすので基本的に推奨しません。

そもそも、C#における日時はまずNodaTimeを検討し、よりカジュアルに使いたい場合はDateTimeOffsetを使えないか検討し、DateTimeはunspecifiedな日時（時差が無い日時）を扱いたいときの手段として使うのが良いのではないかと思います。


参考
--------

以下のIssueでリフレクションを使う方法が紹介されている

https://github.com/dotnet/runtime/discussions/42153

サンプルコードと利用例

https://wandbox.org/permlink/t2NhM89VmelhaFzx


背景
---------

タイムゾーンについて、開発環境と実行環境で異なるケースがあります。

例えばAzureにおいては基本UTCで動作するようになっていますが、任意のタイムゾーンに置き換えることはできるところはできます。VM等のIaaSならまずできますし、PaaSもサポートがあることがあります。しかし残念なことに[LinuxのAzure Functionsはタイムゾーンを変更できません](https://qiita.com/fukasawah/items/c2484b28b17c5fa20328)でした。

そのため、任意タイムゾーンで動かすことは現実的ではないケースがありました。

で、あればUTCで扱うことにして、開発環境をUTC時間で扱えばよいでしょう。しかしWindowsではOS設定のシステム時計をGMT等のUTC相当のタイムゾーンに合わせる必要があるようでした。現実として開発兼業務PCが支給されている我々は普段は日本時間を使い、テスト時だけはUTCにしたいということがあります（ありませんか？）

なので、何とかできないか調べました。


方法
---------

### 調査

以下のissueでWindowsの場合にリフレクションで置き換えるコメントがあります。これは内部実装によるのでランタイムのアップデートで動かなくなるリスクがあります。

https://github.com/dotnet/runtime/discussions/42153

Linuxは環境変数TZで簡単おきかえられるのでWindowsでもどうにかしてタイムゾーンを変えて起動できるようにしてくれないか、というissueも上がっていますが、まだ対応の見込みはなさそうです。

https://github.com/dotnet/runtime/issues/42241


仕方がないので、上記のWindowsではリフレクションで無理やり置き換える方法を試しました。

https://wandbox.org/permlink/t2NhM89VmelhaFzx

`UseUtcTimeZoneInfo()` というstatic methodが肝です。
TimeZoneInfo.Utcをそのまま使うと例外になるケースがありますが、TimeZoneInfo.CreateCustomTimeZoneでそれっぽい値を作ると回避できます。理由は後述します。


注意
-------------

このあたりの注意を克服できれば、使えるかもしれません。

### UTC時間にするときはダミーのUTC時間を作ったほうがよさそう

`TimeZoneInfo.Utc` をそのまま置き換えて、ConvertTimeで日本時間に変換しようとしたら、次のような例外が発生しました。

> System.ArgumentException: The conversion could not be completed because the supplied DateTime did not have the Kind property set correctly.  For example, when the Kind property is DateTimeKind.Local, the source time zone must be TimeZoneInfo.Local. (Parameter 'sourceTimeZone')

https://github.com/dotnet/runtime/blob/v5.0.13/src/libraries/System.Private.CoreLib/src/System/TimeZoneInfo.cs#L644

原因がよくわかってないですが、`ReferenceEquals(DateTimeKind.Local, DateTimeKind.Utc)`という状態になっていると発生するようでした。
おそらく、リフレクションで無理やり`TimeZoneInfo.Local == TimeZoneInfo.Utc`にすると、DateTimeのKindがLocalの場合に、SourceのTimeZoneInfoはLocalになりますが、[ソースコード上該当箇所](https://github.com/dotnet/runtime/blob/v5.0.13/src/libraries/System.Private.CoreLib/src/System/TimeZoneInfo.cs#L119-L122)でTimeZoneInfoに対応するDateTimeKind値を返す処理でLocalを返すべきはずがUtcを返してしまい[ここのDateTimeのKindとの比較](https://github.com/dotnet/runtime/blob/v5.0.13/src/libraries/System.Private.CoreLib/src/System/TimeZoneInfo.cs#L642)を通せず、例外になっているようです。

もちろん、もともとリフレクションを使って置き換えることが変なので、その先のことまで考えられていないというだけなんですが。

そのため、`TimeZoneInfo.CreateCustomTimeZone`を使い、UTC時間の複製を作ってごまかしています。


### TimeZoneInfo.ClearCachedDataが呼ばれるとリフレクションで置き換えたものが元に戻る

[`TimeZoneInfo.ClearCachedData`がpublic staticメソッド](https://github.com/dotnet/runtime/blob/v5.0.13/src/libraries/System.Private.CoreLib/src/System/TimeZoneInfo.cs#L531-L535)になっていますが呼んではいけません。書き換えたTimeZoneInfo.Localの値がリセットされてしまいます。

また、`DateTime.Ticks == 0` な値(DateTime.MinValue, defaultなど)を書き換えたTimeZoneInfoのConvertTimeに渡しても`TimeZoneInfo.ClearCachedData`メソッドが呼ばれてしまいます。([該当箇所](https://github.com/dotnet/runtime/blob/v5.0.13/src/libraries/System.Private.CoreLib/src/System/TimeZoneInfo.cs#L610))
この挙動が厄介で、単体テストで日時が影響を与えないことがわかっていてもDateTimeにはdefault値を使えません。ConvertTimeを呼ぶ前にはTicksが0でない事をチェックしたほうがよいでしょう。




想定FAQ
-----------

### DateTimeではDateTimeKind.UtcもありUTCを明示的に扱えるのだからUTCで明示的に扱えばいいのでは？

その通りだと思います。

それができなかったのは、当時のNpgsqlの実装で`timestamp with time zone`から`DateTime`のマッピングだと`DateTimeZone.Local`を使ってしていたことによります。これは一見それっぽい挙動に見えますが、PostgreSQLが`timestamp with time zone`をUTC時間で保持している仕様であり本来の「タイムゾーン情報」は登録時に失われます。それをクライアントサイドのシステムのタイムゾーンを元にUTCから変換を行うのでミスマッチが起きるケースがあります。

単純に言えば「Npgsqlで`timestamp with time zone`をマッピングしたたDateTimeはLocalなので注意しましょう」という事です。これは思っていた以上に大変でした。

で、たどり着いたアイディアは「Local==Utc」となるようにすればいいのでは？というところでした。それで調べたところ、上記の方法にたどり着きました。

この件を含めて色々考えた結果、システム上ではUTC時間で統一するという方針として、もし日本時間が欲しければ明示的に変換するということにしました。もちろんロジック上で日本時間を扱うこともありますが、そこはテストとレビューで頑張ります。（海外向けのシステムではないので何とかなってます）


[なおこの挙動はNpgsql 6系では修正され DateTimeZone.Utcを使うようになり、元の挙動はオプションで指定するようになりました。](https://www.npgsql.org/doc/release-notes/6.0.html#timestamp-rationalization-and-improvements)もっと早く来ていれば・・・

### そもそもDateTimeを扱うのが良くないのでは？DateTimeOffsetやNodaTimeをつかうべきでは？

その通りです。

DateTimeOffsetはUTC時間でもLocal時間でも時間軸的に同じ時間であれば比較が可能です。
DateTimeOffsetはDateTimeよりも後発であり、DateTimeより数byteを多く消費する以外は、時差違いの比較も正しく行えますから、動作環境のタイムゾーンの違いに起因した問題はありえません。複数のタイムゾーンを扱うシステムでもとりあえずDateTimeOffsetで統一していればほとんどがうまくいくはずです。Npgsql 3でもDateTimeOffsetはそこそこうまく扱えた気がしますが、採用しませんでした。当時なぜDateTimeに固執していたかは今はもう思い出せません…

NodaTimeは考え方が少し難しいですが、コード上どういう時間を扱っているか明示されるので紛れが一切なくなります。もしタイムゾーンを跨ぐ何かがある場合、苦しんででも導入したほうがよいでしょう。DateTimeの扱いの楽さを考えるとNodaTimeはめんどくさすぎるのですが、これが日時の本当の姿だと私は思います。


### そもそもWindowsの開発環境をやめるべきでは？仮想マシンやWSL2(WSL 1.0)でよくないか？

その通りです。Windows上で動作するアプリケーションを作らない限り、わざわざWindows上で開発するのは一種の縛りプレイといえるでしょう。また様々な方がおり、複数案件を経て色々汚染されたマシン上に共通の開発環境を作るのはかなり大変です。

VSCodeも発達してきているのでWSLやリモートVMで開発するのも良いでしょう。時間があれば移行したい（ない）