Share via


Azure DocumentDB での日付の処理

このポストは、11 月 19 日に投稿された Working with Dates in Azure DocumentDB の翻訳です。

JSON (www.json.org) は、人間にとって解読、記述しやすく、機械にとっても解析、生成しやすい軽量のデータ交換形式です。JSON は DocumentDB の中核となるものであり、データの転送には JSON が使用されます。JSON は JSON として格納され、JSON ツリーにインデックスを作成することで JSON ドキュメント全体に対するクエリを実行できます。JSON は製品に後付けされたものではなく、サービスの根幹を成すものです。
そのため、DocumentDB によってネイティブにサポートされているデータ型 (文字列、数値、ブール値、配列、オブジェクト、Null) が JSON と同じであるのも当然だと言えます。

.NET 開発者は、使い慣れた一部の型が含まれていないことにお気付きでしょう。おそらく、特筆すべきなのは .NET DateTime 型です。DocumentDB で DateTime 型がネイティブにサポートされていないのなら、DateTime 型についてどのように格納、取得、クエリで使用するのでしょうか。

この記事では、その方法についてご説明します。

サンプルとして、以下の POCO オブジェクトを使用することにします。

 public class Order
{
    [JsonProperty(PropertyName="id")]
    public string OrderNumber { get; set; }
    public DateTime OrderDate { get; set; }
    public DateTime ShipDate { get; set; }
    public double Total { get; set; }
}

これは、.NET でシンプルな注文データを表した例です。
これには、OrderDate と ShipDate という 2 つの DateTime プロパティが含まれます。この記事では、主にこの 2 つのプロパティに着目します。
DocumentDB SDK では DateTime オブジェクトを処理するために、既定で ISO 8601 文字列形式への変換が行われます。そのため、以下のコード スニペットに示すように、DocumentDB に Order オブジェクトを渡しただけでは、

 var doc1 = client.CreateDocumentAsync(colLink, new Order {
      OrderNumber = "09152014101",
      OrderDate = DateTime.UtcNow.AddDays(-30),
      ShipDate = DateTime.UtcNow.AddDays(-14), 
      Total = 113.39
});

2 つの .NET DateTime プロパティは以下のような文字列として格納されます。

 {
    "OrderDate": "2014-09-15T23:14:25.7251173Z",
    "ShipDate": "2014-09-30T23:14:25.7251173Z"
}

上記の文字列はそのままでも解読できそうなのに、何が問題なのでしょうか。

DocumentDB では、数値フィールドに対して範囲ベースのインデックスをサポートしており、範囲によるクエリ (例: where field > 10 and field < 20) を実行することができます。日付の範囲を指定したクエリ (昨日よりも古いレコード、先週行われた注文、今日発送された注文など) を実行する場合、スキャンによる大量のリソース消費を防ぐためには、文字列で表した日付を数値に変換して、範囲インデックスを使用できるようにする必要があります。

DocumentDB や JSON では、特定の型に依存せずに DateTime を表せます。この特性を利用すれば、アプリケーションの要件に最適な処理を効果的に行うことができます。

 

この記事では DateTime プロパティを処理する方法を 2 種類ご紹介しますが、日時を効率的に処理する方法はほかにも多数あります。

ここから先は DateTime をエポック値、つまり特定の日付からの秒数として処理します。ここでは 1970 年 1 月 1 日 00:00 を起点としますが、データによっては別の日時から開始することもできます。たとえば、システムで今日以降の注文のみを処理する場合は、今日を起点とするとよいでしょう。この記事で紹介するサンプルのシステムには過去の注文が多数含まれるため、過去の日付をさかのぼる必要があるとします。

以下は、DateTime をエポック値に変換する簡単な .NET 拡張メソッドです。

 public static class Extensions
{
    public static int ToEpoch(this DateTime date)
    {
        if (date == null) return int.MinValue;
        DateTime epoch = new DateTime(1970, 1, 1);
        TimeSpan epochTimeSpan = date - epoch;
        return (int)epochTimeSpan.TotalSeconds;
    }
}

では、これをアプリケーションに使用するにはどうすればよいでしょうか。

これには 2 種類の方法があり、アプリケーションの要件に応じて最適な方法を選択することができます。

1 つ目は、DateTime そのものの代わりに DateTime を表す数値フィールドを格納する方法です。

これを最も簡単に行うには、JSON で処理できるようにシリアル化および逆シリアル化するカスタム コードを実装します。ここでは、JSON.NET を使用して、DateTime プロパティの既定の動作を変更するカスタムの JsonConverter を作成します。

そのためには、JsonConverter の抽象クラスを拡張して ReadJson および WriteJson メソッドを上書きするクラスを定義します。

 public class EpochDateTimeConverter : JsonConverter
{
    ...
}

以下は、.NET DateTime を受け取り、先ほど作成した ToEpoch() 拡張メソッドを使用して数値として出力する WriteJson メソッドです。

 public override void WriteJson(JsonWriter writer, object value, JsonSerializer serializer)
{
    int seconds;
    if (value is DateTime)
    {
        DateTime dt = (DateTime)value;
        if (!dt.Equals(DateTime.MinValue))
            seconds = dt.ToEpoch();
        else
            seconds = int.MinValue;
    }
    else
    {
        throw new Exception("Expected date object value.");
    }

    writer.WriteValue(seconds);
}

反対に、JSON から .NET への逆シリアル化に使用する ReadJson メソッドも作成します。このメソッドでは、1970 年 1 月 1 日 (UTC) からの秒数を表す数値を引数として、それに相当する .NET DateTime を返します。

 public override object ReadJson(JsonReader reader, Type type, object value, JsonSerializer serializer)
{
    if (reader.TokenType == JsonToken.None || reader.TokenType == JsonToken.Null) 
        return null;

    if (reader.TokenType != JsonToken.Integer)
    {
        throw new Exception(
            String.Format("Unexpected token parsing date. Expected Integer, got {0}.",
            reader.TokenType));
    }

    int seconds = (int)reader.Value;
    return new DateTime(1970, 1, 1).AddSeconds(seconds);
}

このメソッドをアプリケーションで使用するには、DateTime プロパティを JsonConverter 属性で修飾する必要があります。これで、DateTime プロパティがシリアル化または逆シリアル化された場合に、JSON.NET では既定の動作に代わってカスタムのコードが使用されます。

     public class Order
    {
        [JsonProperty(PropertyName="id")]
        public string OrderNumber { get; set; }

        [JsonConverter(typeof(EpochDateTimeConverter))]
        public DateTime OrderDate { get; set; }

        [JsonConverter(typeof(EpochDateTimeConverter))]
        public DateTime ShipDate { get; set; }

        public double Total { get; set; }
    }

シリアル化を行うと、DateTime は JSON の数値に変換され、DocumentDB に格納できる数値となります。

 {
    "OrderDate": 1408318702,
    "ShipDate": 1408318702 
}

DocumentDB で数値に対して範囲を指定したクエリを効率的に実行するには、DocumentCollection を作成する際に、数値フィールドを含むパスに範囲インデックスを定義する必要があります。

以下の例では、カスタムの IndexPolicy を使用して DocumentCollection を作成しています。ここでは、10 桁の数値を処理するため、範囲インデックスの数値の有効桁数として 7 バイトを指定しています。数値の範囲が小さい場合は、より少ない有効桁数を指定できます。

 var collection = new DocumentCollection
{
    Id = id
};

//ドキュメント内のすべてのプロパティでハッシュ インデックスを使用するように、既定の IncludePath を設定
collection.IndexingPolicy.IncludedPaths.Add(new IndexingPath
{
    IndexType = IndexType.Hash,
    Path = "/",
});

//範囲ベースのクエリを実行する対象の Order に 2 つの追加のパスを定義
collection.IndexingPolicy.IncludedPaths.Add(new IndexingPath
{
    IndexType = IndexType.Range,
    Path = "/\"OrderDate\"/?",
    NumericPrecision = 7
});

collection.IndexingPolicy.IncludedPaths.Add(new IndexingPath
{
    IndexType = IndexType.Range,
    Path = "/\"ShipDate\"/?",
    NumericPrecision = 7
});

collection = client.CreateDocumentCollectionAsync(dbLink, collection).Result;

上記のコードを実装すると、DateTime プロパティに対して以下のように簡単にクエリを実行できます。

 //DateTime 用の ToEpoch 拡張メソッドを使用して「7 日前」をエポック値に変換
int epocDateTime DateTime.UtcNow.AddDays(-7).ToEpoch();

//クエリ文字列を作成
string sql = string.Format("SELECT * FROM Collection c WHERE c.OrderDate > {0}", epocDateTime);

//クエリを実行し、リスト形式で結果を取得
var orders = client.CreateDocumentQuery<Order>(col.SelfLink, sql).ToList();

これが 1 つ目の方法で、非常に効率的に機能します。ただし、デメリットはデータベースに人間が解読できる日付の文字列が保持されないことです。別のアプリケーションがデータベースに接続した場合、カスタムの逆シリアル化用のコードが必ずしも実行されるとは限りません。人間は頭の中でエポック値の変換を実行できないため (少なくとも筆者はそうです)、返された値の処理が難しくなります。

2 つ目の方法は、文字列で表した解読可能な DateTime フィールドを保持したまま、ドキュメントにもう 1 つフィールドを追加して、数値で表した DateTime を格納する方法です。

それを行うには、以下のような新しいカスタムの型を作成します。

 public class DateEpoch
{
   public DateTime Date { get; set; }
   public int Epoch
   {
       get
       {
           return (this.Date.Equals(null) || this.Date.Equals(DateTime.MinValue))
               ? int.MinValue
               : this.Date.ToEpoch();
        }
    }
}

次に、以下のように Order オブジェクトの 2 つの DateTime プロパティをこの新しい型に変換します。

 public class Order
{
    [JsonProperty(PropertyName = "id")]
    public string OrderNumber { get; set; }

    public DateEpoch OrderDate { get; set; }

    public DateEpoch ShipDate { get; set; }

    public double Total { get; set; }
}

上記のコードを実装すると、DocumentDB にオブジェクトを渡した場合に以下のような JSON が返されます。

 {
    "OrderDate": {
        "Date": "2014-09-15T23: 14: 25.7251173Z",
        "Epoch": 1408318702
    },
    "ShipDate": {
        "Date": "2014-09-30T23: 14: 25.7251173Z",
        "Epoch": 1408318702
    }
}

1 つ目の方法と同様に、DocumentCollection にカスタムの IndexPolicy を定義する必要があります。ただし、今回はエポック値のみに範囲のパスを追加します。文字列の日付のパスをインデックスから除外することもできますが、既定のハッシュ インデックスを保持しておけば、必要に応じて等値演算を行うことができます。

 var collection = new DocumentCollection
{
    Id = id
};

//ドキュメント内のすべてのプロパティでハッシュ インデックスを使用するように、既定の IncludePath を設定
collection.IndexingPolicy.IncludedPaths.Add(new IndexingPath
{
    IndexType = IndexType.Hash,
    Path = "/",
});

//範囲ベースのクエリを実行する対象の Order2 に 2 つの追加のパスを定義
collection.IndexingPolicy.IncludedPaths.Add(new IndexingPath
{
    IndexType = IndexType.Range,
    Path = "/\"OrderDate\"/\"Epoch\"/?",
    NumericPrecision = 7
});

collection.IndexingPolicy.IncludedPaths.Add(new IndexingPath
{
    IndexType = IndexType.Range,
    Path = "/\"ShipDate\"/\"Epoch\"/?",
    NumericPrecision = 7
});

//Order2 の日付フィールドの Date 部分は 
//インデックスを作成しないよう除外することもできるが、 
//等値演算を行うことができるようにハッシュ インデックスを保持しておく

collection = client.CreateDocumentCollectionAsync(dbLink, collection).Result;

この方法を使用した場合のクエリでは、以下の LINQ クエリを実行できます。以下では、この方法のメリットを示すために、先ほどの例のように SQL ではなく LINQ を使用しています。

 var orders = from o in client.CreateDocumentQuery<Order2>(col.SelfLink)
    where o.OrderDate.Epoch >= DateTime.Now.AddDays(-7).ToEpoch()
    select o;

または、以下の LINQ のラムダ構文を使用することもできます。

 var orders2 = client.CreateDocumentQuery<Order2>(col.SelfLink)
    .Where(o => o.OrderDate.Epoch >= DateTime.UtcNow.AddDays(-7).ToEpoch())
    .ToList();

もちろん、LINQ を使用できない場合には、先ほどの SQL 構文を使用でき、同じ結果が得られます。

 string sql = String.Format("SELECT * FROM c WHERE c.OrderDate.Epoch >= {0}", 
                 DateTime.UtcNow.AddDays(-7).ToEpoch());

2 つ目の方法には、1 つ目の方法と比較して、主に 2 つのメリットがあります。1 つは、JSON.NET などの特定のツール専用のシリアル化を実行するカスタム コードを使用しないため、他の JSON のシリアル化コードや .NET 以外の言語でも使用できます。この方法は、Node.JS や Python などでも問題なく機能します。もう 1 つは、人間が解読できる DateTime 値を文字列として保持するため、別のアプリケーションでデータのクエリを行う場合にも、解読できる日付の値が取得できます。

この方法はより多くのストレージを必要とし、ドキュメントのサイズも多少大きくなりますが、上記のようなメリットが得られることから、ストレージのコストやドキュメントのサイズが気にならない場合は利用をお勧めします。

以上で、DocumentDB における DateTime プロパティの処理についての説明は終わりです。

この記事でご紹介したサンプル コードは、こちらからダウンロードできます。

Azure DocumentDB の利用を開始するには、Azure.com のサービス ドキュメントのページ (https://azure.microsoft.com/ja-jp/documentation/services/documentdb/) にアクセスしてください。このページでは、DocumentDB を利用するために必要なすべての資料を入手することができます。