日時データの取り扱いに注意？Azure SearchとCosmosDB

本記事は[ulgeek Advent Calendar 2017](https://qiita.com/advent-calendar/2017/ulgeek)、ラスト25日目のエントリです！

# はじめに
私は近頃C#で、Microsoft Azureの「Azure Search」と「Azure Cosmos DB」を併用したWEBアプリケーションを開発しています。よい使い方を探り探り試行していたところ、日時の情報を含むデータを取り扱った際にいくつか不可解な挙動が見受けられました。

 * データを登録してそのまま取得すると、どうも登録した内容と9時間ずれた値が返ってくることがある
 * さらにAzure SearchとCosmos DBとを比べた時に、ずれるデータが異なる

挙動を把握してその対応を考えるために調査・検証をおこないましたので、この記事ではその結果をご紹介します。
どちらか一方のサービスであっても、使用していて日時データの扱いに困っている方の理解の助けになればと思います。

なおDateTime型の性質など重要な点については触れますが、それぞれのサービス自体の説明を含む基本的な要素の説明については省略しています。
また、各検証の結果は実行する環境によって違いが出る可能性があることをご留意ください。


# 環境

<dl>
  <dt>ロケール</dt>
  <dd>日本</dd>
  <dt>言語</dt>
  <dd>C# 7.0</dd>
  <dt>実行環境</dt>
  <dd>.NET Core 2.0</dd>
  <dt>ライブラリ</dt>
  <dd>Microsoft.Azure.DocumentDB.Core　　ver1.6.0.0</dd>
  <dd>Microsoft.Azure.Search　　ver3.0.0.0</dd>
</dl>

# 仕様の調査
C#において日時を扱うデータ型といえば、DateTime型とDateTimeOffset型が挙げられます。
それぞれの型の仕様について改めて確認したのち、Azure Search、Cosmos DBのドキュメントから今回の検証に関係ありそうな要素を洗い出します。

### DateTime型
まずはDateTime型ですが、これは日時の値だけでなく、「その日時がどのロケールの日時を表すか」の情報を限定的に保持することが可能です。それがKindプロパティです。
これにはDateTimeKind列挙型に定義された3種類の値を持たせることができ、それぞれ以下の意味を表します。

<dl>
  <dt>DateTimeKind.Utc</dt>
  <dd>その日時がUTCを表す</dd>
  <dt>DateTimeKind.Local</dt>
  <dd>その日時が実行環境のローカル日時を表す（今回の場合、JSTに相当）</dd>
  <dt>DateTimeKind.Unspecified</dt>
  <dd>UTCかローカル日時か分からない。</dd>
  <dd>インスタンス生成時にKindプロパティを指定しなかった場合、この値をもつ。</dd>
</dl>

このKindプロパティの設定値の違いは、挙動の検証の際に重要になりそうですね。

ちなみにこのKindプロパティですが、あくまでそのインスタンスに対してToLocalTime / ToUniversalTimeメソッドを実行するときに参照されるだけで、インスタンス同士の差を計算する際には参照されない（してくれない）ようです。

<details>
<summary>具体例はこちら</summary>
<div>
せっかくなので、ToLocalTime / ToUniversalTimeメソッドの呼び出しやインスタンス間の差計算がどういう結果になるか示しておきます。
KindプロパティがUnspecifiedのインスタンスは、扱いに特に注意が必要そうな様子も見て取れます。

```csharp
var local = new DateTime(2017, 12, 25, 20, 0, 0, DateTimeKind.Local);    // JST
var utc = new DateTime(2017, 12, 25, 20, 0, 0, DateTimeKind.Utc);    // UTC
var unspec = new DateTime(2017, 12, 25, 20, 0, 0);     // 未指定なので、DateTimeKind.Unspecified

// Kindがローカルのものを変換
var localToLocal = local.ToLocalTime();    // 2017-12-25 20:00:00 <- JSTのまま
var localToUtc = local.ToUniversalTime();  // 2017-12-25 11:00:00 <- UTCに

// KindがUTCのものを変換
var utcToLocal = utc.ToLocalTime();        // 2017-12-26 05:00:00 <- JSTに
var utcToUtc = utc.ToUniversalTime();      // 2017-12-25 20:00:00 <- UTCのまま

// Kindを明示しなかったものを変換
var unsToUtc = unspec.ToUniversalTime();   // 2017-12-25 11:00:00 <- Unspecified = Localだと判断されてUTCに
var unsToLocal = unspec.ToLocalTime();     // 2017-12-26 05:00:00 <- Unspecified = Utcだと判断されてJSTに

// インスタンス間の差を計算。9時間の時差はあるが、差は0という結果。
TimeSpan sub = local.Subtract(utc);    // local - utc も可。 sub.TotalSeconds == 0
```
</div></details>

### DateTimeOffset型
続いてDateTimeOffset型ですが、これは型名の通り、日時の情報とともに、そのタイムゾーンの値をTimeSpan構造体で持つことができます。DateTime型がロケールについて3パターンの情報しか持てないのに対して、こちらはUTCとの時差を明示することでいろいろなロケールの日時を表すことができる特徴があります。

今回の検証にあたって、特別意識すべき仕様もないように見受けられました。

### Azure Search
Azure Searchにおける、日時を扱うデータ型はEdm.DateTimeOffset型です。DateTimeOffsetと聞くとタイムゾーンの情報もうまく保持してくれそうなものですが、[公式ドキュメント](https://docs.microsoft.com/ja-jp/rest/api/searchservice/Supported-data-types)の説明にはこうあります。
>When you upload DateTimeOffset values with time zone information to your index, Azure Search normalizes these values to UTC.
>For example, 2017-01-13T14:03:00-08:00 will be stored as 2017-01-13T22:03:00Z.
>If you need to store time zone information, you will need to add an extra column to your index.

要はタイムゾーンの情報からUTCを算出できればそれに変換してしまい、その後は値が表していたタイムゾーンの情報は保持しない、保持したければもう1つ項目を用意しなさいということですね。
この変換が不可解な挙動に影響を与えている1つであることはこの時点で既に確実ですが、その条件は不明確なため、DateTime型のKindプロパティ、DateTimeOffset型のタイムゾーン情報と紐づけて検証します。

### Cosmos DB
Cosmos DBはドキュメントDBなのでDB上でのデータ型はありませんが、日時に関してはISO 8601に則ったシリアライズをおこなって保持するということが[公式ドキュメント](https://docs.microsoft.com/en-us/azure/cosmos-db/working-with-dates#storing-datetimes)に記載されています。

特に日時の変換に触れた内容は見つかりませんでしたが、とはいえ登録したデータと取得したデータに違いが出るケースを検証の発端として目にしているため、Azure Searchと同様の観点で検証をおこないます。


# 検証
今回は、次のような日時の値を持ったドキュメントをAzure Search、Cosmos DB双方にプログラムから登録し、以下の2点を確認します。

 * Azureポータル上から、登録された各値を確認
 * 登録したドキュメントをプログラムから取得し、その際の各値を確認

```csharp
var document = new DateTimeCheck{
    // DateTime型
    // Kindプロパティ：Local
    Jst = new DateTime(2017, 12, 25, 13, 0, 0, DateTimeKind.Local),

    // Kindプロパティ：明示しないので、Unspecifiedになる
    Unspec = new DateTime(2017, 12, 25, 13, 0, 0),

    // Kindプロパティ：Utc
    Utc = new DateTime(2017, 12, 25, 13, 0, 0, DateTimeKind.Utc),


    // DateTimeOffset
    // UTCからのタイムゾーンのズレ：9時間(日本時間)
    JstOffset = new DateTimeOffset(2017, 12, 25, 13, 0, 0, TimeSpan.FromHours(9)),

    // UTC
    UtcOffset = new DateTimeOffset(2017, 12, 25, 13, 0, 0, TimeSpan.FromHours(0))
};
```

## 仮説
仕様の調査を総合してみると、まずAzure Searchについて

 * 登録の際に、各日時値のタイムゾーン設定(Kindプロパティ、オフセット)がローカルのものはUTCに変換される
 * 設定がUTCあるいは不明の場合、そのままの日時値が登録される
 * 取得の際は、登録されたUTCの値が取得される

の3つを仮説として立てられます。反面、Cosmos DBについては情報が不足しており

 * 登録の際は、タイムゾーン設定に依らずそのままの日時値が(シリアライズされて)登録されそうである
 * 取得の際に、タイムゾーンに関わるなんらかの条件で変換が発生しているのではないか

という弱い仮説が立てられるにすぎません。

以上の仮説を念頭に確認をしていきます。

## 検証
### Azure Search
上で示したドキュメントを挿入してポータルから確認してみました。
以下のようになっています。

```json

{
    "jst": "2017-12-23T04:00:00Z",
    "unspec": "2017-12-23T13:00:00Z",
    "utc": "2017-12-23T13:00:00Z",
    "jstOffset": "2017-12-23T04:00:00Z",
    "utcOffset": "2017-12-23T13:00:00Z"
}

```
仮説の通り、UTCからの時差があることを明示したJst、JstOffsetの値がUTCに変換されて登録されました。明示しなかったUnspecの値は、(ToUniversalTimeメソッドの結果とは異なり)UTCであると見なされて変換はされないようですね。

さて、続いてこれをプログラムから取得してみます。結果は以下の通りです。
※元のドキュメントとの比較が目的のため、文法上はおかしなコードです。

```csharp
// 元のドキュメント
document = new DateTimeCheck
{
    Jst = new DateTime(2017, 12, 23, 13, 0, 0, DateTimeKind.Local),
    Unspec = new DateTime(2017, 12, 23, 13, 0, 0, DateTimeKind.Unspecified),
    Utc = new DateTime(2017, 12, 23, 13, 0, 0, DateTimeKind.Utc),

    JstOffset = new DateTimeOffset(2017, 12, 23, 13, 0, 0, TimeSpan.FromHours(9)),
    UtcOffset = new DateTimeOffset(2017, 12, 23, 13, 0, 0, TimeSpan.FromHours(0))
};

// 取得したドキュメント
searchOutput = DateTimeCheck 
{
    Jst = DateTime(2017, 12, 23, 4, 0, 0, DateTimeKind.Utc),
    Unspec = DateTime(2017, 12, 23, 13, 0, 0, DateTimeKind.Utc),
    Utc = DateTime(2017, 12, 23, 13, 0, 0, DateTimeKind.Utc),

    JstOffset = DateTimeOffset(2017, 12, 23, 4, 0, 0, TimeSpan.FromHours(0)),
    UtcOffset = DateTimeOffset(2017, 12, 23, 13, 0, 0, TimeSpan.FromHours(0)),
};
```

UTCに変換されて登録された、そのままの値をもったインスタンスを取得できました。


### Cosmos DB
Cosmos DBについても、Azure Searchと同じドキュメントを登録して結果を確認してみます。

```json

{
    "jst": "2017-12-23T13:00:00+09:00",
    "unspec": "2017-12-23T13:00:00",
    "utc": "2017-12-23T13:00:00Z",
    "jstOffset": "2017-12-23T13:00:00+09:00",
    "utcOffset": "2017-12-23T13:00:00+00:00"
}
```

今回用意したパターンの範囲ではいずれも変換は起こらず、登録しようとしたデータがそのまま登録されているようです。
さて、続いてこれをプログラムから取得します。
※文法については同上

```csharp
// 元のドキュメント
document = new DateTimeCheck
{
    Jst = new DateTime(2017, 12, 23, 13, 0, 0, DateTimeKind.Local),
    Unspec = new DateTime(2017, 12, 23, 13, 0, 0, DateTimeKind.Unspecified),
    Utc = new DateTime(2017, 12, 23, 13, 0, 0, DateTimeKind.Utc),

    JstOffset = new DateTimeOffset(2017, 12, 23, 13, 0, 0, TimeSpan.FromHours(9)),
    UtcOffset = new DateTimeOffset(2017, 12, 23, 13, 0, 0, TimeSpan.FromHours(0))
};

// 取得したドキュメント
cosmosOutput = DateTimeCheck
{
    Jst = DateTime(2017, 12, 23, 13, 0, 0, DateTimeKind.Local),
    Unspec = DateTime(2017, 12, 23, 13, 0, 0, DateTimeKind.Unspecified),
    Utc = DateTime(2017, 12, 23, 13, 0, 0, DateTimeKind.Utc),

    JstOffset = DateTimeOffset(2017, 12, 23, 13, 0, 0, TimeSpan.FromHours(9)),
    UtcOffset = DateTimeOffset(2017, 12, 23, 22, 0, 0, TimeSpan.FromHours(9))
};
```

DateTime型については登録したままの値で取得できましたが、DateTimeOffset型についてはUTCの13時を示していた値がJST(ローカルの時間)の22時に変換されてしまいました。日時として同じ値ではありますが、DateTimeOffset型を使うとタイムゾーンをローカルの日時に合わせるように値が変換されるようです。

## 検証結果まとめ
### Azure Search
* 登録
  * タイムゾーンがローカルの日時を表すデータは、いずれの型もUTCに変換される
  * タイムゾーンを不明としたDateTime型のデータは、そのままの日時がUTCと見なされて登録される
  * UTCを表すデータは、いずれの型もそのままの日時で登録される
* 取得
  * どのようなデータも、登録されたUTCの日時がそのまま取得される

### Cosmos DB
* 登録
    * どのようなデータも、元のデータの表すままに登録される
* 取得
    * UTCを表すデータは、DateTimeOffset型で取得する場合のみ、ローカルの日時に変換される
    * そのほかのデータは登録された日時がそのまま取得される


# 実装方針の紹介
最後に簡単に、こういった検証を踏まえた上で、私が今どういった方針で日時データを取り扱っているかとその理由を紹介します。

方針としては、DateTime型を「値としてはローカルの（扱いたいままの）日時の値としつつ、KindプロパティはUtcにセットする」というものです。この方針は以下2つの利点から採用を決定しました。

 * Azure SearchでもCosmos DBでも、登録・取得を通して値の変化が発生しないことが確実である
 * UTCと指定することで日時の表現としてユニークとなり、実行環境などの違いによる解釈の揺れも生じない

あくまで「KindプロパティはUtcにセットする」のは様々な変化を抑制するためのおまじないのようなもので、実態としてはローカルの日時を表しているということに注意が必要ですね。

DateTimeOffset型については、以下の理由から使用しないこととしました。

 * Azure SearchとCosmos DBとで、値の変換が入る際のデータの種類・タイミング・方向が異なり意識することが増えてしまう
 * 単一のタイムゾーンだけの日時を扱うシステムであるため、オフセットの情報を正確に指定しておく必要がない

複数のタイムゾーンを扱う必要がある場合は追加の考慮が必要でしょう。ただしその場合もDateTime型を同じように使用して、加えて公式ドキュメントを参考に「それが表現する日時とUTCとの時差を表す項目を追加する」という対応が考えられると思います。


# おわりに
今回はAzure SearchとCosmos DBを対象に、検証を通して日時データを扱う際の留意点を確認しました。併せて実装方針も紹介させていただきましたが、自分のところで試すとこうなったよ、自分たちはこんな方針で実装しているよ、などありましたらぜひコメントください。皆さんの情報をもとに、精度を高めていければ幸いです。