読者です 読者をやめる 読者になる 読者になる

Azure Cosmos DB入門(2)

本コンテンツは「Azure Cosmos DB入門」の(2)です。

ryuichi111std.hatenablog.com

2 Cosmos DBの主要概念

前回の「Azure CosmosDB入門(1)」では、Cosmos DBの「特徴」と「Hello Cosmos DB」と題した簡単なサンプルについて説明しました。
早く具体的なデータベース操作に入りたいところですが、その前にCosmos DBを利用する上で押さえておきたい主要な概念について説明します。

ここでは、各技術要素の説明に関して「主要概念」の範囲にとどめます。
RU(Request Unit)やパーティショニングなどは、より複雑な内容を理解する必要がありますが、それは「Azure CosmosDB入門(4)」で説明する事とし、ここではCosmos DBプログラミングを始めるための概要を理解することを目的とします。

2.1 データモデルとアクセスAPI

Cosmos DBでは、以下のような多様なデータモデルをサポートします。

  • ドキュメント
  • テーブル
  • グラフ

f:id:daigo-knowlbo:20170521182812p:plain

各データモデルに対しては、それぞれに対応したアクセス用のAPIが提供されています。
「アクセス用API」と「データモデル」は密接な関係を持ちます。AzureにCosmos DBアカウントを作成する際は、初期作成パラメータとして「アクセス用API」を選択します。すると「アクセス用API」に対応した「データモデル」でデータベースアカウントが作成されます。
以下が「アクセス用API」の一覧と、それに対応する「データモデル」です。

  • SQL(DocumentDB)用API : ドキュメント データモデル
  • MongoDB用API : ドキュメント データモデル
  • Gremlin用API : グラフ データモデル
  • Table(Key-Value)用API : Key-Value データモデル

f:id:daigo-knowlbo:20170522005959p:plain

2.1.1 atom-record-sequence(ARS)

マルチデータモデルによるデータ保持を行うCosmos DBですが、内部では「atom-record-sequence(ARS)」というデータ保持の仕方をしています。
公式ドキュメントでは以下のように説明されています。

  • atomは、string/bool/numberのようなプリミティブ型の小さなセットから構成されます
  • recordは、atomから構成される構造体です
  • sequenceは、atom / record / sequenceから構成される配列です

つまり、以下のようなイメージでしょうか。

f:id:daigo-knowlbo:20170521231534p:plain

2.1.2 AzureポータルでCosmos DBを作成する

アクセス用API / データモデルの種類は、Cosmos DBをAzureに作成する際に決定します。
また、Azure Cosmos DBを作成すると「データベースアカウント」が作成されます。これはAzure Cosmos DB全体においてユニークなIDを持つアカウントとなります。
1つのデータベースアカウントにおいて、複数のデータモデルのデータを混在させることはできません。

既に「1.2 Hello Cosmos DB」で、Azureポータルを利用してCosmos DBデータベースを作成する手順については説明しました。
以下は、それぞれのアクセスAPI(=データモデル)のCosmos DBデータベースアカウント作成のイメージです。

(1) DocumentDBデータモデル

f:id:daigo-knowlbo:20170521153217p:plain

(2) MongoDBデータモデル

f:id:daigo-knowlbo:20170521153236p:plain

(3) Graphデータモデル

f:id:daigo-knowlbo:20170521153306p:plain

(4) Tableデータモデル

f:id:daigo-knowlbo:20170521153331p:plain

2.2 各種データモデルのデータ構造

「ドキュメント」「グラフ」「テーブル」の各データモデルにおける「データの構造」について見ていきます。

2.2.1 「ドキュメント」モデル

「ドキュメント」データモデルでは以下のような構造のデータを保持します。

f:id:daigo-knowlbo:20170521231729p:plain

※厳密な構造上はコレクション配下にストアドプロシージャなども含まれますが、それらは適時後述します。

(1) データベースアカウント

「データベースアカウント」がルート要素となります(Azureポータルで作成したリソースのルートです。Azureポータルで「Cosmos DBを作成する」=「Cosmos DBデータベースアカウントを作成する」となります)。

(2) データベース

データベースアカウントの下に「データベース」をN個作成することができます。

(3) コレクション

データベースの下に「コレクション」をN個作成することができます。

(4) ドキュメント

コレクションの下の「ドキュメント」が、データとしての1項目であり、JSON形式のデータで表現されます。

SQL Serverにマッチさせて考えると以下のようになります。

JSONドキュメントデータはスキーマレスであり、異なる形式のデータを同一コレクション内に混在させることができます。
ただし、全く関係のない種類のデータを混在させるというのではなく、同一の概念を表すが、データ毎に詳細は異なるデータ群を同一コレクションに保存することが多いと思います。
以下はショッピングサイトを想定したケースで、商品である「書籍」と「CD」を表したJSONドキュメントです。「商品」という共通概念のデータを表しますが、書籍はページ数(PageCount)という独自の属性があり、CDには「トラック数(TrackCount)」という独自の属性があります。

書籍を表すJSON
{
  "Id": "001",
  "Title": "0から始めるCosmos DB",
  "Price": 2800,
  "PageCount": 360
}
CDを表すJSON
{
  "Id": "102",
  "Title": "毎日10分TOEIC Listening",
  "Price": 1600,
  "TrackCount": 30
}

2.2.2 「グラフ」 モデル(2017/5現在Preview版)

これはDocumentDBがCosmos DBとなったタイミング(2017/5/10)でサポートされた新たなデータモデルです。

f:id:daigo-knowlbo:20170521184357p:plain

グラフデータベース自体は既に世の中には存在し、Neo4Jなどが代表的なものとして存在していました。
Cosmos DBでも同様のグラフデータモデルをサポートし、クエリー言語として「Gremlin」をサポートします。

Vertex と Edge

グラフデータベースの概念の詳細にはここでは深くは触れませんが簡単に・・・
グラフデータベースは一般的に、「twitterfacebookの友達の繋がり」を表す例で説明されることが多いです。それ以外にも「交通機関の駅の繋がりからの最短経路検索」「物流における配送経路の決定」「関連する商品をグラフ管理することにより顧客への商品レコメンド抽出」等々で活用の用途があります。

Vertexは人や物を表す「頂点」であり、それからEdgeはVertex間の関係性を表す「辺・端線」となります。
Vertexの直接的な繋がり、複数のVertexを経由した間接的繋がり、またEdgeは繋がりの種類・方向を表すことができます。

2.2.3 「テーブル」モデル(2017/5現在Preview版)

「テーブル」データモデルは、その名前の通りテーブル構造でデータを保持します。ただしRDBのテーブルと異なり、スキーマレスのテーブル構造となります。
以下の図のように「1つのテーブル」に異なるスキーマ(異なる列構造)のデータを格納することができます。

f:id:daigo-knowlbo:20170521155634p:plain

「Azure入門」と「解剖C#」は書籍データであり「PageCount列」があります。
美女と野獣 bluelay」はブルーレイいコンテンツなので「PageCount列」は無く「Duration列」があります。
コカ・コーラ」については「PageCount列」も「Duration列」も存在しません。

Azure Storage Tableとの関係

テーブルは従来の「Azure Storage Table」のCosmos DB版との位置付けです。
APIもAzure Storage Table APIと互換性のあるインターフェイスが用意されています。
「Cosmos DBのテーブル」と「Azure Storage Table」の使い分けは以下のようになります。

2.3 パーティショニング

Cosmos DBのバックエンドでは、固定化されたサイズのSSDベースのストレージが動作しており、ラッチフリーを実現したBW-Treeのデータベースシステムが動作しています。
また、Cosmos DBは「パーティション」という単位でデータを分割して保持します。パーティションSSDストレージに関連付けられて保持されることになります。
コレクション群を保持する論理リソースを「コンテナ」と呼び、コンテナはパーティションやサーバーを跨いで保持されます。

コレクションに対して用意されるパーティション数は、Cosmos DBにより自動的に適時決定されます(ストレージサイズや予約されたスループット(RU)により決定)。外から実際の動作を確認する限り、コレクションのStorage Capacityが10GB設定の場合には 1パーティション 構成、250GB設定の場合には 25パーティション 構成が取られるようです。

Storage Capacity=250GB(以上)の設定を行う場合、コレクションに対して「パーティションキー」というものを設定する必要があります。
パーティションキー」は、ドキュメント内の データ要素 を指定します。
パーティションキーが同一のドキュメント(データ)は、同一のパーティションに保存されます。
例えば以下のようなドキュメントに対して /cityName をパーティションキーとした場合、cityNameがTokyoのドキュメントは必ず同一パーティションに保存されます。

{
  'id': 1,
  'userName': 'ryuichi',
  'cityName': 'Tokyo'
  ...省略...
}

パーティションについては、設計上の注意点と、良くないパーティション設計を行った場合の問題点があります。

さらに詳細なパーティションについての情報は「Azure CosmosDB入門(4)」で説明します。

2.3.1 Azureポータルでパーティションキーを設定

Data Explorerでコレクションを追加する際に「Partition key」を設定することができます。

f:id:daigo-knowlbo:20170521185102p:plain

2.3.2 プログラムでパーティションキーを設定

以下がプログラムでパーティションキーを設定するコードスニペットです。

client = new DocumentClient(
  new Uri("https://cosmosdb.documents.azure.com:443/"), "[キー]");
await client.CreateDatabaseIfNotExistsAsync(new Database { Id = "Database1" });

var collection = 
  new DocumentCollection { Id = "Collection1" };
collection.PartitionKey.Paths.Add("/cityName");

await client.CreateDocumentCollectionIfNotExistsAsync(
  UriFactory.CreateDatabaseUri("Database1"),
  collection);

DocumentCollectionオブジェクトのPathsプロパティに「/cityName」を追加しています。
コレクション作成時(CreateDocumentCollectionIfNotExistsAsync())の引数で渡されるDocumentCollectionオブジェクトを通じて、指定のパーティションキーが適用されます。

2.4 RU(Request Unit)

RU(Request Unit)はCosmos DBでは非常に重要な概念です。
RUは「Cosmos DBに対する操作(クエリーや書き込み)を行うのに要する計算量」の単位になります。

2.4.1 1RUとは?

RUは「処理を行い為の計算量」の単位ですが、では 1RU とはどれくらいの処理量でしょうか。
公式ドキュメントにおいて 1RU は以下とされています。

The baseline of 1 request unit for a 1KB item corresponds to a simple GET by self link or id of the item.

つまり、
「1KBのデータを、セルフリンクもしくはIDでシンプルに取得する操作に要するRUが約1」
ということです。
大きなデータの取得、複雑な抽出条件の指定等を行うと、そのデータ抽出操作を行うのに必要なRU値が増えます。

2.4.2 RUはコレクションに割り当てる

RUは「コレクション」に対して 100RU/秒 単位で割り当てます。
RUは「扱うデータサイズ・量」「抽出条件の複雑度」「リクエストのトラフィック量」により必要量が決まります。また、RUのサイズがCosmos DBの課金における大きな要素となりますので、割り当てサイズの決定は非常に重要になります。

2.4.3 RUという概念のすばらしさ

従来のハードウェアリソースを意識した、つまり、CPUの割り当て数・メモリの割り当て量を基準としたスケール概念の場合、データ量の増大・抽出条件の複雑化が発生すると、結果取得のパフォーマンス(応答時間)の遅延につながります。
これに対してCosmos DBでは、CPUやメモリのような物理リソースではなく、RUという抽象単位の割り当てを行う考え方をとります。RU/秒は1秒間にこなせる計算量であり、応答時間については「read <10ms、write <15ms」がSLAで保障され続けます。

つまり、Cosmos DBでは「コレクション毎に、1秒間に処理できるRU」を予約設定します。予約したRUを使い切ろうが、使わなかろうが、予約したRU分が課金対象となります。そして、「read <10ms、write <15ms」のパフォーマンスを発揮するのに要するCPUやメモリはAzureバックエンドの話であり、Cosmos DB利用者は意識する必要がないし、見ることすらできません。バックエンドのハードウェインフラを意識することなく、一定の契約パフォーマンスが常に得られる、まさに PaaS の素晴らしさといえるでしょう。

2.4.4 予約RUの設定方法

コレクションに対するRUの設定は、Azureポータルやプログラムから行うことができます。

(1) Azureポータルによる設定

Azureポータルでは、コレクションを作成する際にRUの設定を行うことができます。
項目としては「Throughput」となります。
以下の画面キャプチャに示すように、Storage Capacityが「Fixed(10GB)」の場合には 400~10,000 RU の範囲で、Storage Capacityが「Unlimited」の場合には 2,500~100,000 RU の範囲で設定が可能です。

f:id:daigo-knowlbo:20170522011055p:plain

f:id:daigo-knowlbo:20170522011121p:plain

初期作成後もData Explorerでスループットの変更は柔軟に行うことができます。

f:id:daigo-knowlbo:20170522011537p:plain

(2) プログラムによる設定

1000RUのスループットを定義したDocumentDBコレクションを作成するコードスニペットは以下の通りです。

// 接続用クライアントオブジェクトの作成
this.client = new DocumentClient(
  new Uri(EndpointUrl), PrimaryKey);

// データベースの作成
await this.client.CreateDatabaseIfNotExistsAsync( new Database { Id = DatabaseId });

// コレクションの作成
var collection = new DocumentCollection { Id = CollectionId };
var requestOptions = new RequestOptions() { OfferThroughput = 1000 };
await this.client.CreateDocumentCollectionIfNotExistsAsync(
  UriFactory.CreateDatabaseUri(DatabaseId),
  collection,
  requestOptions);

RequestOptionsオブジェクトの「OfferThroughputプロパティ」に、必要なスループット(RU)を指定してコレクションの作成メソッドを呼び出します。
10,000RUを超える値を設定する場合は、パーティションキーを設定する必要があります。設定しなかった場合、例外が発生します。

2.4.5 消費RUの確認方法

Cosmos DBにおいては RU/秒 を予約設定して運用します。
つまり、アプリケーション実装におけるCosmos DBへの読み込み・書き込みがどれだけのRUを消費するかは、RUの予約数を決定するうえで非常に重要です。
前述のようにCosmos DB公式ドキュメントにおいても 1RU の目安は以下とされています。

「1KBのデータを1件取得する(一貫性レベルはSession)」操作に要するRUが約「1」

実際に実装したアプリケーション上の操作におけるRUの消費量は以下の方法で確認することが出来ます。

(1) Cosmos DBへの操作を行った際のResponseヘッダ「x-ms-request-charge」で確認

DocumentDB API(接続オプションをTCPではなくHTTPSとしている場合)・REST APIによる操作を行っている場合にはHTTPS通信を監視するFiddlerなどを使って確認することができます。   以下は「WHERE Age > 20」という条件付きであるコレクションのドキュメント検索を行った際のHTTPS通信内容です。

f:id:daigo-knowlbo:20170521174333p:plain

右下のペインにおいてHTTP Response Headerを確認しています。
「x-ms-request-charge」項目が今回の検索を行うのに消費したRUであり 3.72 という値が返却されています。

(2) Azureポータルのクエリーエクスプローラで確認

Azureポータルにはクエリーエクスプローラ機能が用意されています。
以下の画面のようにコレクションに対して任意のSQLを実行すると「請求の要求」という項目で消費RUが表示されます。

f:id:daigo-knowlbo:20170521175115p:plain

(3) GetLastRequestStatisticsコマンド(MongoDB)で確認

GetLastRequestStatisticsコマンドを呼び出すことで直前の処理の消費RUを確認することができます。
C#上から呼び出す例は以下です(詳細については「Azure CosmosDB入門(3)」で説明します)。

MongoClient client = new MongoClient(settings);
var database = client.GetDatabase(dbName);

...任意のMongoDBアクセス処理

var result = database.RunCommand<BsonDocument>(new CommandDocument { { "getLastRequestStatistics", 1 } });
Console.WriteLine( "消費RU={0}", result["RequestCharge"].RawValue);

2.4.6 RUの話は深い・・・(後半に続く)

記述の通りRUはCosmos DBでは非常に重要な要素です。
Cosmos DBを運用する際のコストに対して非常に大きな比重を持ちますし、運用においてのシステムとして利用するスケーラビリティのバリューでもあります。
また、まだ触れていない「インデックス」「一貫性レベル」の設定状態によっても変動があります。
そして、以下のような要素についての解説がまだまだ必要なのですが、これはまた後半で説明する事とします。

  • RU/秒 と RU/分
  • RU超過時のレスポンスコード429
  • etc…

RUについての理解はひとまずここまでにし、もう少し具体的なCosmos DBプログラミングの理解を進めた後で、さらに深堀したほうが理解しやすいと考える為です。

Azure Cosmos DB入門(1)

本コンテンツは「Azure Cosmos DB入門」の(1)です。

ryuichi111std.hatenablog.com

1 Cosmos DBとは

Cosmos DBはAzureで提供されるデータベースサービスの1つです。データベースの種類としては、いわゆる「NoSQL」データベースとなります。
公式ドキュメントにおいては、まず以下のように説明されています。

「グローバル分散可能なマルチモデルデータベースサービス」

1.1 特徴

Cosmos DBの代表的な特徴は以下ような点があげられます。

  • グローバル分散可能
  • 柔軟なスケーリングが可能
  • 超高速な応答速度
  • マルチデータモデル
  • 多様な一貫性モデル
  • 高いレベルのSLA

上記項目について、それぞれ順番に見ていきましょう。

1.1.1 グローバル分散可能

Azureは世界30ヶ所以上のリージョンでサービスが提供されています。Cosmos DBは、これらのリージョンに対してレプリケーションが可能です。
つまり、世界中に分散してデータを保持することができます。
世界規模のサービスを提供する場合、データベース(Cosmos DB)とアプリケーション(例えばAppService)を各リージョンにレプリカ&配置することで、エンドユーザーが最寄りのリージョンに対してのアクセスでサービスを利用するようにすることができます。つまり、地理的に遠いリージョンへのアクセスによるネットワーク遅延を回避することが可能になります。勿論、そのうえでレプリケーションされたCosmos DBはバックエンドで自動的にリージョン間のデータ同期が行われます。また、特定リージョンのダウン時には自動的なフェールオーバーが行われます。

1.1.2 柔軟なスケーリングが可能

スループットのスケーリングもCosmos DBの大きな特徴になります。
クラウドサービスの特徴として自由自在にスケール調整が可能なことがあげられます。
Cosmos DBは、更に、オンライン状態のまま5秒以内にパフォーマンスのスケール変更が可能となっています。スケーリングの調整は、Azureポータル上からもプログラム上からも簡単に可能となります。
また、Cosmos DBにおけるスループットは「RU(Request Unit)」という単位になっており、これが課金対象の大きな要素となります。
ストレージ容量についてもペタバイト単位までスケール可能となっています。
そしてCosmos DBは無制限にスケーリングすることが可能です。

1.1.3 超高速な応答速度

Cosmos DBに限らず、AWSのDynamoDBにしても、それからオンプレ運用可能なApache CASSANDRAにしても、NoSQLデータベースの特徴として「超高速である」ということは周知の事実でしょう。
対比されるデータベースシステムとしてRDBがありますが、NoSQLでは「テーブル間リレーションという概念を持たない」「限られたトランザクション機能とする」等の機能実装上の方針の違いから「高速な動作」を手に入れています。
Cosmos DBでは、同一リージョン内における読み取り・書き込みのレイテンシーに対して以下の表に示すようなミリ秒単位の速度を保証しています。

Read(1KB) Indexed Write(1KB)
50% < 2ms < 6ms
99% < 10ms < 15ms

※「同一リージョン内」でのレイテンシーとは、つまり「Japan WestにApp Serviceをパブリッシュ&同じくJapan WestにCosmos DBを作成」というケースにおける App ServiceからCosmos DBへのデータアクセスにおけるレイテンシーを指します。

1.1.4 マルチデータモデル

これもCosmos DBの大きな特徴になります。
従来のNoSQLでは、CASSANDRAは「Key-Value」、MongoDBは「ドキュメント」・・・のようにデータベースプロダクト(サービス)と保持するデータモデル形式は固定化されていました。
しかし、Cosmos DBでは以下のような異なるデータモデルを保持することができます。

  • 「ドキュメント」
  • 「Graph」
  • 「Key-Value」

またそれらのデータモデルに合わせて、アクセス用のAPIもそれぞれ用意されています。

  • 「DocumentDB用API(ドキュメント)」
  • 「MongoDB用API(ドキュメント)」
  • 「Gremlin用API(Graph)」
  • 「Tables用API(Key-Value)」

1.1.5 多様な一貫性モデル

従来の一般的なNoSQLデータベースでは、データの一貫性に関して以下の2つのモデルを提供していました(AWS DynamoDBの該当)。

  • 「Strong」 : 強固な一貫性を提供する(ただし速度性能を犠牲にする)
  • 「Eventual」 : (一貫性を維持するためにタイムラグを要するが)最終的にデータの一貫性を提供する(速度性能重視)

Cosmos DBでは、上記に加えて「Bounded-staleness」「Session」「Consistent Prefix」という3つのモードが用意されています。(合計5つの一貫性モードが用意されています)

1.1.6 高いレベルのSLA

Cosmos DBでは以下について99.9%という非常に高いSLA保証を行っています。

SLAの詳細は以下に公開されています。
https://azure.microsoft.com/en-us/support/legal/sla/cosmos-db/v1_0/

1.1.7 Cosmos DBは自分たちが普通に使うものか?

上記で紹介した6つの特徴から「Cosmos DBは"なんかすごい"」という事が感じ取れると思います。と同時に「世界規模の分散」や「無制限なスケーリング」などを必要とするような、例えばfacebookだったりtwitterだったりのようなシステムを自分たちは作りはしないし、これって使う必要あるの?という思いを持つ方も多いことでしょう。
しかし、Cosmos DBがレイテンシーに関して「読み込み <10ms、書き込み <15ms」の保証をしているデータベースシステムである、という点だけでも使う価値を見出せるのではないかと思います。
多くの既存の、そして現役の開発の現場(これは、特定企業内のBtoBなシステム、不特定多数の企業を対象としたBtoBのクラウドサービス、不特定多数の個人を対象としたBtoCのサービス、を含む)においても、RDBによるデータアクセスがボトルネックとなるケース、その為のパフォーマンスチューニングを追加で講じる必要に直面するケースは多いのではないでしょうか。
RDBをCosmos DBにすべて置き換える」という選択肢もありますし、「RDBとCosmos DBのハイブリット運用」という選択肢もあるでしょう。
また、Cosmos DBはACIDなトランザクション処理をサポートしていますが、従来のRDBよりも制約があり設計上の工夫が必要である点があります。その様なことから、マスタデータをRDBで、読み取り効率を上げたい部分に関してはRDB → Cosmos DBへのデータ連携を走らせ、Cosmos DBからのリードパフォーマンスの優位性を享受する、ということも考えられます。
それから、データ要件によってはRDBのテーブル構造がマッチしないケースもあります。例えば私が所属する会社の製品にワークフロー(稟議書)を扱うものがあります。稟議書は「申請者から何人もの承認者を経るパス」を持ちます。パスは単純なケースもあれば、稟議書の内容によって複雑な分岐をする場合もあります。下図のようなパス(フロー)をデータベースに保持する場合、テーブル構造よりもグラフ構造で保存した方が自然な形で保存することができますし、速度も圧倒的に早いでしょう。

f:id:daigo-knowlbo:20170521014319p:plain

※私自身もNoSQLをガンガンシステムに適用しているわけではないので、この辺りは業界全体としても経験を蓄積し、並行してさらなる技術の進歩が起こりながらベストプラクティスを求めていくことになると思います。

1.2 Hello Cosmos DB

ではHello Worldならぬ、Hello Cosmos DBとして、Cosmos DBを利用するファーストステップを行ってみようと思います。

1.2.1 AzureポータルでCosmos DBを作成

ブラウザで「https://portal.azure.com」を開きます。
サイドメニューから「新規」を選択します。

f:id:daigo-knowlbo:20170521011619p:plain

フィルターに「Azure Cosmos DB」と入力します。

f:id:daigo-knowlbo:20170521011628p:plain

「Azure Cosmos DB」を選択します。

f:id:daigo-knowlbo:20170521011649p:plain

「作成」ボタンをクリックします。

f:id:daigo-knowlbo:20170521011702p:plain

任意の「ID」(ここでは cosmosdb)を入力します。
API」は「SQL(DocumentDB)」を選択します。 「リソースグループ」の任意の値(ここでは cosmosdb)を入力します。

f:id:daigo-knowlbo:20170521011712p:plain

以上で「cosmosdb」というデータベースアカウントのCosmos DB(DocumentDBデータモデル)が作成されました。

1.2.2 Azureポータルでデータを操作

実際の運用アプリケーションでは、データの作成・検索・更新等の操作はプログラムから行われますが、ここではまず、Azureポータル上からCosmos DBのデータ操作を行いその雰囲気を感じ取ります。

(1) データの入れ物の準備(データベースとコレクション)

作成したCosmos DBをAzureポータルで表示した画面が以下です。

f:id:daigo-knowlbo:20170521011720p:plain

「Data Explorer(Preview)」を選択すると、以下の画面が表示されます。
SQL ServerのManagement Studioのようなノリでデータ操作を行うことができます。

f:id:daigo-knowlbo:20170521011727p:plain

「New Collection」をクリックすると以下の画面が表示されます。
このデータベースアカウント「cosmosdb」に対して、「データベース」→「コレクション」を作成します。コレクションは「データ項目」を入れる入れ物になります(詳細は後述)。
ここでは以下の内容にてデータベースとコレクションを作成します。
* Database id = ExampleDb1
* Collection id = ExampleCol1
* Storage capacity = Fixed(10GB)
* Throughput = 400
* Partition key = なし

f:id:daigo-knowlbo:20170521011738p:plain

データを格納する入れ物が完成しました(コレクションを作成すると「Throughput = 400」というパフォーマンスを提供するためのリソースがAzure上に確保された状態となります。そ の為、課金の対象となりますので、作成→放置→課金継続・・・にはご注意ください)。

(2) データ(ドキュメント)の作成

データベース・コレクションの作成を行った後の「Data Explorer(Preview)」の表示は以下の通りです。
左側のツリーから「Documents」を選択し、「New Document」を選択します。

f:id:daigo-knowlbo:20170521011748p:plain

JSONエディタが表示されるので、適当にデータを編集してSaveボタンをクリックします。
※本チュートリアルでは、Cosmos DBを作成する際に、APIとして「SQL(DocumentDB)」を選択したためデータモデルが「ドキュメント(つまりJSONデータモデル)」となっています。

f:id:daigo-knowlbo:20170521011758p:plain

作成されたデータは以下の通りです。自ら編集した物以外の項目「rid」「self」「etag」「attachments」「_ts」が付加されていますが、これらはCosmos DBが内部的に自動で付加したものです。

f:id:daigo-knowlbo:20170521011809p:plain

この後の上記手順を繰り返し、適当なデータ5件を作成しました。

f:id:daigo-knowlbo:20170521011817p:plain

作成したデータ一覧
[
  {
    "id": "1",
    "FirstName": "Ryuichi",
    "LastName": "Daigo",
    "FavoriteDB": "Cosmos DB",
    "Age": 17,
    "_rid": "2cRoAIH5HAACAAAAAAAAAA==",
    "_self": "dbs/2cRoAA==/colls/2cRoAIH5HAA=/docs/2cRoAIH5HAACAAAAAAAAAA==/",
    "_etag": "\"0f00fff9-0000-0000-0000-591fcf700000\"",
    "_attachments": "attachments/",
    "_ts": 1495256943
  },
  {
    "id": "2",
    "FirstName": "Hiroaki",
    "LastName": "Sakamoto",
    "FavoriteDB": "SQL Server",
    "Age": 25,
    "_rid": "2cRoAIH5HAADAAAAAAAAAA==",
    "_self": "dbs/2cRoAA==/colls/2cRoAIH5HAA=/docs/2cRoAIH5HAADAAAAAAAAAA==/",
    "_etag": "\"0f0006fa-0000-0000-0000-591fd0400000\"",
    "_attachments": "attachments/",
    "_ts": 1495257151
  },
  {
    "id": "3",
    "FirstName": "Susumu",
    "LastName": "Akiyama",
    "FavoriteDB": "Neo4j",
    "Age": 36,
    "_rid": "2cRoAIH5HAAEAAAAAAAAAA==",
    "_self": "dbs/2cRoAA==/colls/2cRoAIH5HAA=/docs/2cRoAIH5HAAEAAAAAAAAAA==/",
    "_etag": "\"0f0007fa-0000-0000-0000-591fd05b0000\"",
    "_attachments": "attachments/",
    "_ts": 1495257178
  },
  {
    "id": "4",
    "FirstName": "Hiroko",
    "LastName": "Takada",
    "FavoriteDB": "Oracle",
    "Age": 32,
    "_rid": "2cRoAIH5HAAFAAAAAAAAAA==",
    "_self": "dbs/2cRoAA==/colls/2cRoAIH5HAA=/docs/2cRoAIH5HAAFAAAAAAAAAA==/",
    "_etag": "\"0f0008fa-0000-0000-0000-591fd07d0000\"",
    "_attachments": "attachments/",
    "_ts": 1495257212
  },
  {
    "id": "5",
    "FirstName": "Yoko",
    "LastName": "Kitahara",
    "FavoriteDB": "Mongo DB",
    "Age": 18,
    "_rid": "2cRoAIH5HAAGAAAAAAAAAA==",
    "_self": "dbs/2cRoAA==/colls/2cRoAIH5HAA=/docs/2cRoAIH5HAAGAAAAAAAAAA==/",
    "_etag": "\"0f000efa-0000-0000-0000-591fd0aa0000\"",
    "_attachments": "attachments/",
    "_ts": 1495257257
  }
]

(3) データの検索

引き続き「Data Explorer(Preview)」を使用します。
「Edit Filter」ボタンをクリックします。

f:id:daigo-knowlbo:20170521011826p:plain

フィルター入力テキストボックスが有効になるので、以下のように抽出条件を設定して「Apply Filter」ボタンをクリックします。抽出条件はSQLライクに記述可能です。

where c.Age > 20

f:id:daigo-knowlbo:20170521011846p:plain

条件に該当する3件に絞り込みが行われました。

f:id:daigo-knowlbo:20170521011854p:plain

同様の機能を持ったツールとして「ドキュメント エクスプローラー」「クエリ エクスプローラー」というものがAzureポータル上で提供されています。

(4) 後片付け

コレクションを作成したままにしておくと課金対象となってしまうので削除しておきましょう。
きれいにコレクションの上のデータベースごと削除することとします。
「Data Explorer(Preview)」でデータベース(ExampleDb1)の右側の「・・・」をクリックするとポップアップメニューが表示されます。「Delete Database」を選択します。

f:id:daigo-knowlbo:20170521011903p:plain

確認画面が表示されるので、削除対象のデータベース名を入力して「OK」ボタンをクリックします。

f:id:daigo-knowlbo:20170521011913p:plain f:id:daigo-knowlbo:20170521011834p:plain

1.2.3 プログラムでデータを操作

次に、前述の「Azureポータルでデータを操作」で行った操作をC#プログラムから行います。

(1) 接続情報の確認

Azureポータルで「キー」を選択します。「読み取り/書き込みキー」タブを選択します。
URI」と「プライマリキー」を控えておきましょう。

f:id:daigo-knowlbo:20170521011922p:plain

(2) Visual Studio 2017プロジェクトを作成

メニュー「ファイル→新規作成→プロジェクト」を選択し、表示された「新しいプロジェクト」に以下の内容を入力します。

  • プロジェクトテンプレート=「Windows クラシックデスクトップ→コンソールアプリ(.NET Framework)」
  • 名前=HelloCosmosDb

f:id:daigo-knowlbo:20170521011932p:plain

(3) CosmosDB接続ライブラリ(NuGetパッケージ)を追加

ソリューションエクスプローラからHelloCosmosDbプロジェクトをマウス右ボタンクリックして、表示されたポップアップメニューから「NuGet パッケージの管理」をクリックします。

f:id:daigo-knowlbo:20170521011942p:plain

表示されたNuGetパッケージ管理画面の参照タブで「Microsoft.Azure.DocumentDB」を選択して「インストール」ボタンをクリックします。

f:id:daigo-knowlbo:20170521011949p:plain

「ライセンスへの同意」ダイアログが表示されますが、そのまま「同意する」をクリックします。

f:id:daigo-knowlbo:20170521011958p:plain

(4) データの入れ物の準備(データベースとコレクション)

話を単純にする為に、コンソールアプリケーションのProgram.csにコードを追記していくこととします。
また、以下、各処理のコードスニペットを紹介し、最後に全体の実装を掲載する事とします。
Cosmos DB関連の名前空間を適時usingします。

using Microsoft.Azure.Documents;
using Microsoft.Azure.Documents.Client;

接続情報と作成するデータベース名・コレクション名を定数定義しておきます。

private const string EndpointUrl = "https://cosmosdb.documents.azure.com:443/";
private const string PrimaryKey = "キー";
private const string DatabaseId = "ExampleDb1";
private const string CollectionId = "ExampleCol1";

データベースとコレクションを作成するコードは以下の通りです。

private DocumentClient client;
...
public async Task<bool> CreateDatabaseCollection()
{
  // 接続用クライアントオブジェクトの作成
  this.client = new DocumentClient(
    new Uri(EndpointUrl), PrimaryKey);

  // データベースの作成
  await this.client.CreateDatabaseIfNotExistsAsync( new Database { Id = DatabaseId });

  // コレクションの作成
  var collection = new DocumentCollection { Id = CollectionId };
  await this.client.CreateDocumentCollectionIfNotExistsAsync(
    UriFactory.CreateDatabaseUri(DatabaseId),
    collection);

  return true;
}

まずは、接続クライアントオブジェクト「DocumentClient」を作成します。作成したDocumentClientオブジェクトは、後で別の処理でも流用することを考慮し、clientフィールド変数に保持することにしました。
DocumentClientは接続オブジェクトであるため、エンドポイントおよび接続用キーをコンストラクタで渡すことになります。
データベースの作成には「CreateDatabaseIfNotExistsAsync()メソッド」を呼び出します。引数でデータベースIDを指定します。メソッド名から想像がつくように「存在しなかったら作成、存在したらそのまま処理が正常終了」します。
コレクションの作成には「CreateDocumentCollectionIfNotExistsAsync()メソッド」を呼び出します。第1引数に対して「UriFactory.CreateDatabaseUri()メソッド」呼び出しの戻り値を渡しています。第2引数のDocumentCollectionオブジェクトによりコレクションIDを指定しています。

(5) データ(ドキュメント)の作成

前述と同様に以下のようなスキーマのデータ(ドキュメント)を作成します。

{
  "id": "1",
  "FirstName": "Ryuichi",
  "LastName": "Daigo",
  "FavoriteDB": "Cosmos DB",
  "Age": 17,
}

上記スキーマに合致したモデルクラス Person を用意します。

namespace HelloCosmosDb
{
  public class Person
  {
    public string Id { get; set; }

    public string FirstName { get; set; }

    public string LastName { get; set; }

    public string FavoriteDB { get; set; }

    public int Age { get; set; }
}

Personオブジェクトを5件の固定データで作成する実装は以下の通りです。
DocumentClientオブジェクト(this.client)の「UpsertDocumentAsync()」メソッドを呼び出します。Upsertの名前から分かるように「キーが同じドキュメントが既に存在すれば更新を、存在しなければ作成」処理が行われます。
第1引数は、どのコレクションに対してドキュメントを作成するかを表します。
第2引数は、作成するオブジェクトそのものを指定します。

public async Task<bool> CreateDocuments()
{
  string[] id = { "1", "2", "3", "4", "5" };
  string[] firstName = { "Ryuichi", "Hiroaki", "Susumu", "Hiroko", "Yoko" };
  string[] lastName = { "Daigo", "Sakamoto", "Akiyama", "Takada", "Kitahara" };
  string[] favoriteDB = { "Cosmos DB", "SQL Server", "Neo4J", "Oracle", "MongoDB" };
  int[] age = { 17, 25, 36, 32, 18 };
  for(int i=0; i<5; i++)
  {
    var person = new Person()
    {
      Id =id[i],
      FirstName=firstName[i],
      LastName=lastName[i],
      FavoriteDB = favoriteDB[i],
      Age = age[i]
    };

    var newDocument = 
      await this.client.UpsertDocumentAsync(
        UriFactory.CreateDocumentCollectionUri(DatabaseId, CollectionId),
        person);
  }

  return true;
}

(6) データの検索

ここまでで、cosmosdbデータベースアカウント → ExampleDb1データベース → ExampleCol1 上に5件のドキュメントが作成された状態です。
Ageが20より多きドキュメントを検索してコンソール出力します。

public void SearchDocuments()
{
  Uri collectionUrl = UriFactory.CreateDocumentCollectionUri(DatabaseId, CollectionId);

  var query =
    this.client.CreateDocumentQuery<Person>(collectionUrl)
    .Where(c => c.Age > 20);

  foreach (var person in query)
  {
    Console.WriteLine(person);
    Console.WriteLine("-----");
  }
}

DocumentClientオブジェクト(this.client)の「CreateDocumentQuery()」メソッドでクエリーオブジェクトを作成することができます。Linqメソッドk構文でWhere句を記述することができます。

(7) ソース全体と実行結果

上記に説明した実装およびコードスニペットをまとめた実装の全体コードは以下になります。

//Person.cs
using System;

namespace HelloCosmosDb
{
  public class Person
  {
    public string Id { get; set; }

    public string FirstName { get; set; }

    public string LastName { get; set; }

    public string FavoriteDB { get; set; }

    public int Age { get; set; }

    public override string ToString()
    {
      return string.Format(
        "ID:{0}\r\nFirstName:{1}\r\nLastName:{2}\r\nFavoriteDB:{3}\r\nAge:{4}",
        this.Id, this.FirstName, this.LastName, this.FavoriteDB, this.Age);
    }
  }
}
// Program.cs
using System;
using System.Linq;
using System.Threading.Tasks;

using Microsoft.Azure.Documents;
using Microsoft.Azure.Documents.Client;

namespace HelloCosmosDb
{
  class Program
  {
    private const string EndpointUrl = "https://cosmosdb.documents.azure.com:443/";
    private const string PrimaryKey = "キー";
    private const string DatabaseId = "ExampleDb1";
    private const string CollectionId = "ExampleCol1";
    private DocumentClient client;

    static void Main(string[] args)
    {
      Console.WriteLine(UriFactory.CreateDatabaseUri(DatabaseId));

      var program = new Program();
      program.CreateDatabaseCollection().Wait();
      program.CreateDocuments().Wait();
      program.SearchDocuments();
    }

    public async Task<bool> CreateDatabaseCollection()
    {
      // 接続用クライアントオブジェクトの作成
      this.client = new DocumentClient(
        new Uri(EndpointUrl), PrimaryKey);

      // データベースの作成
      await this.client.CreateDatabaseIfNotExistsAsync( new Database { Id = DatabaseId });

      // コレクションの作成
      var collection = new DocumentCollection { Id = CollectionId };
      await this.client.CreateDocumentCollectionIfNotExistsAsync(
        UriFactory.CreateDatabaseUri(DatabaseId),
        collection);

      return true;
    }

    public async Task<bool> CreateDocuments()
    {
      string[] id = { "1", "2", "3", "4", "5" };
      string[] firstName = { "Ryuichi", "Hiroaki", "Susumu", "Hiroko", "Yoko" };
      string[] lastName = { "Daigo", "Sakamoto", "Akiyama", "Takada", "Kitahara" };
      string[] favoriteDB = { "Cosmos DB", "SQL Server", "Neo4J", "Oracle", "MongoDB" };
      int[] age = { 17, 25, 36, 32, 18 };
      for(int i=0; i<5; i++)
      {
        var person = new Person()
        {
          Id =id[i],
          FirstName=firstName[i],
          LastName=lastName[i],
          FavoriteDB = favoriteDB[i],
          Age = age[i]
        };

        var newDocument = 
          await this.client.UpsertDocumentAsync(
            UriFactory.CreateDocumentCollectionUri(DatabaseId, CollectionId),
            person);
      }

      return true;
    }

    public void SearchDocuments()
    {
      Uri collectionUrl = UriFactory.CreateDocumentCollectionUri(DatabaseId, CollectionId);

      var query =
        this.client.CreateDocumentQuery<Person>(collectionUrl)
        .Where(c => c.Age > 20);

      foreach (var person in query)
      {
        Console.WriteLine(person);
        Console.WriteLine("-----");
      }
    }
  }
}

実行結果は以下の通りです。

// 実行結果

ID:600bb1b7-fdfc-479c-b295-8fc3f589b45a
FirstName:Hiroaki
LastName:Sakamoto
FavoriteDB:SQL Server
Age:25
-----
ID:a2b5fd96-de4f-4283-bd10-78bfd5056505
FirstName:Susumu
LastName:Akiyama
FavoriteDB:Neo4J
Age:36
-----
ID:82e9770d-7a9f-4b0e-adbb-849051912154
FirstName:Hiroko
LastName:Takada
FavoriteDB:Oracle
Age:32
-----
続行するには何かキーを押してください . . .

Azure Cosmos DB入門(目次)

「Azure Cosmos DB入門」目次

DocumentDB が Azure Cosmos DB としてリニューアルされたので、改めてこのサービスの全体像を整理したコンテンツ(ブログ)を「Cosmos DB入門」として書こうと思います。
目次は以下の通り。順次コンテンツを追加していく予定です。

  • Azure CosmosDB入門(1)
    • 1 Cosmos DBとは
      • 1.1 特徴
        • 1.1.1 グローバル分散可能
        • 1.1.2 柔軟なスケーリングが可能
        • 1.1.3 超高速な応答速度
        • 1.1.4 マルチデータモデル
        • 1.1.5 多様な一貫性モデル
        • 1.1.6 高いレベルのSLA
        • 1.1.7 Cosmos DBは自分たちが普通に使うものか?
      • 1.2 Hello Cosmos DB
        • 1.2.1 AzureポータルでCosmos DBを作成
        • 1.2.2 Azureポータルでデータを操作
        • 1.2.3 プログラムでデータを操作
  • Azure CosmosDB入門(2)
    • 2 Cosmos DBの主要概念
      • 2.1 データモデルとアクセスAPI
        • 2.1.1 atom-record-sequence(ARS)
        • 2.1.2 AzureポータルでCosmos DBを作成する
      • 2.2 各種データモデル
        • 2.2.1 「ドキュメント」モデル
        • 2.2.2 「グラフ」 モデル(2017/5現在Preview版)
        • 2.2.3 「テーブル」モデル(2017/5現在Preview版)
      • 2.3 パーティショニング
      • 2.4 RU(Request Unit)
        • 2.4.1 1RUとは?
        • 2.4.2 RUはコレクションに割り当てる
        • 2.4.3 RUという概念のすばらしさ
        • 2.4.4 予約RUの設定方法
        • 2.4.5 消費RUの確認方法
        • 2.4.6 RUの話は深い・・・(後半に続く)
  • Azure CosmosDB入門(3)準備中…
    • 3 Cosmos DBプログラミング
      • 3.1 DocumentDBを使おう
      • 3.2 MongoDBを使おう
      • 3.3 Graphを使おう
      • 3.4 Tableを使おう
  • Azure CosmosDB入門(4)準備中…
    • 4 Cosmos DBをもっと知りたい
      • 4.1 RU(Request Unit)詳細
      • 4.2 インデックス
      • 4.3 一貫性レベル
      • 4.4 Change Feed
      • 4.5 グローバルレプリケーション
      • 4.6 障害復旧

はじめに

先日 2017/5/10-12 に開催された Build 2017 において多数の新しい技術の発表がなされました。
その中の1つとして Azure Cosmos DB も発表されました。

Azure Cosmos DBは従来 DocumentDB として公開されていたサービスに置き換わるものです。厳密には「Cosmos DB が DocumentDB の機能を内包している」という関係性を持ちます。

Azure Cosmos DBは、2010年に「Project Florence」として開始されました。大規模アプリケーション開発の課題に対処するために、マイクロソフト社内向けに開発されていたサービスです。しかし、グローバルに分散されたアプリケーションの構築は、マイクロソフト以外の課題でもあるとの認識から2015年に「Azure DocumentDB」としてサービスリリースされました。
DocumentDBに多数の機能を追加し、そして2017/5/10に Azure Cosmos DB としてサービスリリースが行われました。

※これまでのDocumentDB情報

Cosmos DBは、従来のDocumentDBの知識やコードはそのまま利用可能です。大幅な機能追加によるリニューアルであり、従来のDocumentDBの機能や仕様がひっくり返されている部分はありません。
つまり、ネット上に公開されているDocumentDBの情報は(完全にマッチしないまでも)引き続きCosmos DBにおいて有効な情報です。

DocumentDBライブラリの「1クエリ」は「1HTTPリクエスト」ではない

豆知識的なメモ ブログです。

私自身、DocumentDBを学ぶ上で「あー、そうなんだぁ。ライブラリがいい意味でも、そうでない意味でも、面倒見てくれてるんだなぁ」と思った点について書いておきます。

※本記事では「FeedOptions.MaxItemCount」と「DocumentDBのREST API呼び出しにおけるHTTPヘッダー「x-ms-max-item-count」」について記述しています。

前提条件

以下のモデルクラスデータが 1,000件 、DocumentDBに保存されているものとします。

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

  public string Name { get; set; }

  public int Price { get; set; }

  public int StockNumber { get; set; }
}

また、ドキュメントは以下のデータベース・コレクションに保存されているものとします。

データベース名: ExampleDB1
コレクション名: ExampleCollection1

ライブラリを使って全件クエリー!

プロジェクトに対してNugetから「Microsoft.Azure.Document」を追加すると以下のようなコードで 1,000件のProductItemオブジェクト を取得することができます。

// リスト1
string EndpointUrl = "https://rddocdb.documents.azure.com:443/";
string PrimaryKey = "[プライマリキー]";

string DatabaseID = "ExampleDB1";
string CollectionID = "ExampleCollection1";

// DocumentClientオブジェクト初期化
DocumentClient client = new DocumentClient(new Uri(EndpointUrl), PrimaryKey);

// 全件取得
Uri documentCollectionUri = 
  UriFactory.CreateDocumentCollectionUri(DatabaseID, CollectionID);
var query = 
  client.CreateDocumentQuery<ProductItem>(documentCollectionUri);
// result変数に1,000件のデータが取得される
var result = query.ToList();

実際に発行されたHTTP

上記コードでは、最後の「result = query.ToList();」を実行したタイミングで DocumentDB へのクエリー要求が行われます。
処理完了後に result 変数には1,000件の ProductItemオブジェクト が取得されます。
FiddlerでHTTP通信を監視すると、以下のようなHTTP通信が行われていることを確認することができます。

f:id:daigo-knowlbo:20170509031327p:plain

10回の /docs へのリクエストが行われています。
また、各リクエスト時の リクエストチャージ(x-ms-request-charge)は 38.1 でした。全部で38.1×10のRUを消費。

つまり、ライブラリを利用するコードとしては「query.ToList()」の1文が、10回のHTTPリクエストに分割された、ということです。

x-ms-max-item-countの影響

全1,000件のデータが10回のHTTPリクエストに分割された理由、それはDocumentClient.CreateDocumentQuery()メソッド呼び出し時のFeedOptions引数に影響します。
リスト1ではCreateDocumentQuery()の第2引数を指定しませんでしたが、CreateDocumentQuery()には第2引数にFeedOptionsオブジェクトを指定することができます。
FeedOptions.MaxItemCountの値が1リクエストで取得する項目の最大件数になります(DocumentDBのREST API呼び出しにおけるHTTPヘッダー「x-ms-max-item-count」値)。
また、FeedOptions.MaxItemCountのデフォルト値は「100」となっています。
つまり、これを省略したリスト1では「1リクエストで取得する最大件数=100」で動作を行いました。

10回のリクエストの途中のHTTPヘッダーを見ると以下のようになっています。

f:id:daigo-knowlbo:20170509030905p:plain

上記キャプチャでは、HTTPレスポンスに「x-ms-continuation: {“token”:“qd4DAJ67FQDIAAAAAAAAAA==”,“range”:{“min”:“”,“max”:“FF”}}」が記述されています。
つまり、HTTPリクエスト・レスポンス間での特定クエリーに対するページング処理が挟み込まれています(10件ずつのページング)。

FeedOptions.MaxItemCountを指定する

では、以下のリスト2のように FeedOptions.MaxItemCount= 1000 を指定してみます。

// リスト2
string EndpointUrl = "https://rddocdb.documents.azure.com:443/";
string PrimaryKey = "[プライマリキー]";

string DatabaseID = "ExampleDB1";
string CollectionID = "ExampleCollection1";

// 1度の最大取得項目数は1,000
var feedOption = new FeedOptions() { MaxItemCount = 1000 };

// DocumentClientオブジェクト初期化
DocumentClient client = new DocumentClient(new Uri(EndpointUrl), PrimaryKey);

// 全件取得
Uri documentCollectionUri = 
  UriFactory.CreateDocumentCollectionUri(DatabaseID, CollectionID);
var query = 
  client.CreateDocumentQuery<ProductItem>(documentCollectionUri, feedOption);
var result = query.ToList();

リスト2を実行すると、以下のように1度のHTTPリクエストで1,000件のProductItemオブジェクトを取得することができます。

f:id:daigo-knowlbo:20170509031939p:plain

ちなみにこの時の消費RUは 380.96 となりました。

DocumentDB でページング処理を行う

「データを一覧してページング表示する」という要件は エンプラ系でも コンシューマー系でも 常にある要件です。
ということで、DocumentDBでデータのページング取得を行う方法を書いておこうと思います。

1. 前提

DocumentDBアカウントには、既に以下のデータが保存されている状態を前提とします。

データベース名: ExampleDB1
コレクション名: ExampleCollection1
ドキュメント: ProductItemドキュメントが1,000件保存されている

ProductItemは以下のようなデータクラスです。

// 保存するドキュメント
using Newtonsoft.Json;

namespace PagingExample
{
  public class ProductItem
  {
    [JsonProperty(PropertyName = "id")]
    public string Id { get; set; }

    public string Name { get; set; }

    public int Price { get; set; }

    public int StockNumber { get; set; }
  }
}

以下のような感じで、適当なデータが入っています。

f:id:daigo-knowlbo:20170507003752p:plain

2. DocumentDB操作管理クラスを用意

DocumentDBを操作する管理クラス(DocumentManager)の実装は以下の通りです。

using System;
using System.Collections.Generic;
using System.Linq;
using Microsoft.Azure.Documents;
using Microsoft.Azure.Documents.Linq;
using Microsoft.Azure.Documents.Client;
using System.IO;
using System.Threading.Tasks;

namespace PagingExample
{
  public class DocumentManager
  {
    private const string EndpointUrl = "https://rddocdb.documents.azure.com:443/";
    private const string PrimaryKey = "[マスターキー]";

    private const string DatabaseID = "ExampleDB1";
    private const string CollectionID = "ExampleCollection1";

    private DocumentClient client;

    // DocumentClientオブジェクトを初期化
    public async Task<bool> InitializeDocumentClient()
    {
      this.client = new DocumentClient(new Uri(DocumentManager.EndpointUrl), DocumentManager.PrimaryKey,
        new ConnectionPolicy
        {
          ConnectionMode = ConnectionMode.Gateway,
          ConnectionProtocol = Protocol.Https
        });

      return true;
    }

    // ProductItemを 10件 ずつ取得
    public async Task<(List<ProductItem>, string)> QueryProductItems(string continuration)
    {
      List<ProductItem> result = null;

      var feedOption = new FeedOptions() {
        MaxItemCount = 10,
        RequestContinuation = continuration };

      Uri documentCollectionUri = 
        UriFactory.CreateDocumentCollectionUri(
          DocumentManager.DatabaseID, DocumentManager.CollectionID);
      var query =
        this.client.CreateDocumentQuery<ProductItem>(
          documentCollectionUri, feedOption)
          .Where( p => p.Price > 700 )
          .AsDocumentQuery();

      var ret = await query.ExecuteNextAsync<ProductItem>();
      
      result = ret.ToList();
      string retContinuration = ret.ResponseContinuation;

      return (result, retContinuration);
    }
  }
}

InitializeDocumentClient()メソッド

DocumentClientオブジェクトを new してメンバーフィールドに保持します。

QueryProductItems()メソッド

引数 continuration は、DocumentDBに「どのページ(というかどの位置から継続抽出するか)」の状態文字列です。1ページ目の取得時には「空文字列」を設定します。
CreateDocumentQuery()を呼び出す際の引数「FeedOptions」で、「1度に取得する件数(MaxItemCount)」と「どのページ(どこから継続取得するかを表す状態文字列(RequestContinuation )」を指定します。
AsDocumentQuery()呼び出して、ExecuteNextAsync()呼び出した結果から、10件の抽出データ(ret.ToList()) と 次のページを読み込む為の継続取得状態文字列(ret.ResponseContinuation)が得られます。
これら2つのデータをタプルで戻り値として呼び出し元に返却しています。
また、サンプルなので Price > 700 という固定的なWhere条件を付加しています。

3. DocumentManagerを呼び出してページングデータ取得

では、前述で用意した DocumentManager を利用してProductItemデータを10件ずつページング取得したいと思います。
以下が実装になります。

var manager = new DocumentManager();
var initResult = await manager.InitializeDocumentClient();

// コンソール出力用ページ番号
int pageNo = 0;

// DocumentDBに投げるcontinuration値(1ページ目は空文字列)
string continuration = "";

do
{
  (List<ProductItem> items, string continuration) queryResult = 
    await manager.QueryProductItems(continuration);

  // 次のページを取得するためのcontinuration文字列取得
  continuration = queryResult.continuration;

  // 取得したデータをコンソール出力
  Console.WriteLine(string.Format("page {0}", pageNo));
  Console.WriteLine(string.Format("continuration {0}", continuration));
  foreach (var item in queryResult.items)
  {
    Console.WriteLine(string.Format("{0} {1}", item.Name, item.Price));
  }
  Console.WriteLine("-----");
  pageNo++;
} while (!string.IsNullOrEmpty(continuration)); // continurationが空だと終端ページに達したということ

データ抽出で得られた(Responseされた)Continuation値を、次のデータ抽出(Request)で利用する形です。

4. 実行結果

実行結果は以下の通りです。

page 0
continuration {"token":"+RID:mi01AOyHIgA7AAAAAAAAAA==#RT:1#TRC:10#FPC:AgEAAAB8AD8AABhwwAEHHHDAAQcccMABBxxwwAEHHHDAAQcccMABLcAHHHDAAQcccMABBxxwwAEHHHDAAQcccMABBxxwwAEHHHDAAQcccMABBxxwwAEHHHDAAQcccMABBxxwwAEHHHDAAQcccMABBxxwwAEHHHDAAQcccMABBxxwwAE=","range":{"min":"","max":"FF"}}
Product_8 800
Product_9 900
Product_18 800
Product_19 900
Product_28 800
Product_29 900
Product_38 800
Product_39 900
Product_48 800
Product_49 900
-----
page 1
continuration {"token":"+RID:mi01AOyHIgBtAAAAAAAAAA==#RT:2#TRC:20#FPC:AgEAAAB2AG8AAGDAAQcccMABBxxwwAEHHHDAAQcccMABBxxwwAEHKsAccMABBxxwwAEHHHDAAQcccMABBxxwwAEHHHDAAQcccMABBxxwwAEHHHDAAQcccMABBxxwwAEHHHDAAQcccMABBxxwwAEHHHDAAQcccMABBxxwwAE=","range":{"min":"","max":"FF"}}
Product_58 800
Product_59 900
Product_68 800
Product_69 900
Product_78 800
Product_79 900
Product_88 800
Product_89 900
Product_98 800
Product_99 900
-----
page 2
continuration {"token":"+RID:mi01AOyHIgCfAAAAAAAAAA==#RT:3#TRC:30#FPC:AgEAAABuAJ+ANcABBxxwwAEHHHDAAQcccMABBxxwwAEHHHDAAQcccMABBxxwwAEHHHDAAQcccMABBxxwwAEHHHDAAQcccMABBxxwwAEHHHDAAQcccMABBxxwwAEHHHDAAQcccMABBxxwwAEHHHDAAQcccMAB","range":{"min":"","max":"FF"}}
Product_108 800
Product_109 900
Product_118 800
Product_119 900
Product_128 800
Product_129 900
Product_138 800
Product_139 900
Product_148 800
Product_149 900
-----
page 3
continuration {"token":"+RID:mi01AOyHIgDRAAAAAAAAAA==#RT:4#TRC:40#FPC:AgEAAABoAN8ABhxwwAEHHHDAAQcccMABBxxwwAEHHHDAAQcccMABI8AHHHDAAQcccMABBxxwwAEHHHDAAQcccMABBxxwwAEHHHDAAQcccMABBxxwwAEHHHDAAQcccMABBxxwwAEHHHDAAQcccMAB","range":{"min":"","max":"FF"}}
Product_158 800
Product_159 900
Product_168 800
Product_169 900
Product_178 800
Product_179 900
Product_188 800
Product_189 900
Product_198 800
Product_199 900
-----
page 4
continuration {"token":"+RID:mi01AOyHIgADAQAAAAAAAA==#RT:5#TRC:50#FPC:AgEAAABiAA8BGHDAAQcccMABBxxwwAEHHHDAAQcccMABBxxwwAEHIMAccMABBxxwwAEHHHDAAQcccMABBxxwwAEHHHDAAQcccMABBxxwwAEHHHDAAQcccMABBxxwwAEHHHDAAQcccMAB","range":{"min":"","max":"FF"}}
Product_208 800
Product_209 900
Product_218 800
Product_219 900
Product_228 800
Product_229 900
Product_238 800
Product_239 900
Product_248 800
Product_249 900
-----

...省略...

-----
page 18
continuration {"token":"+RID:mi01AOyHIgC-AwAAAAAAAA==#RT:19#TRC:190#FPC:AgEAAAAKAL+DA8ABBxxwwAE=","range":{"min":"","max":"FF"}}
Product_908 800
Product_909 900
Product_918 800
Product_919 900
Product_928 800
Product_929 900
Product_938 800
Product_939 900
Product_948 800
Product_949 900
-----
page 19
continuration
Product_958 800
Product_959 900
Product_968 800
Product_969 900
Product_978 800
Product_979 900
Product_988 800
Product_989 900
Product_998 800
Product_999 900
-----
続行するには何かキーを押してください . . .

5. HTTP通信内容

DocumentDBライブラリにより、裏側で具体的にどのようなREST API リクエストとレスポンスが行われたのかをFiddlerで確認します。

f:id:daigo-knowlbo:20170507012230p:plain

上記Fiddler画面から以下のことが分かります。

  • 「/dbs/ExampleDB1/colls/ExampleCollection1/docs」へのページごとのリクエストが行われた
  • FeedOptions.MaxItemCount / FeedOptions.RequestContinuation の値がHTTPリクエストヘッダでそれぞれ「x-ms-max-item-count」「x-ms-continuation」して指定されている
  • HTTPレスポンスヘッダ「x-ms-continuation」で、次のページを読み込むための継続状態文字列が返されている

資料

公式ドキュメントでは以下を見ればいいのかな。

docs.microsoft.com

Common DocumentDB REST request headers | Microsoft Docs

Common DocumentDB REST response headers | Microsoft Docs

DocumentDB のインデックス設定によるRUの変動について

DocumentDBのインデックスについて調べてのでブログに記しておこうと思います。
DocumentDBではデフォルト設定でも結構いい感じに動くようにインデックス設定が行われているのですが、カスタマイズ可能であり、これを最適化するとRUや(インデックス用)データサイズの節約につながります。

※RU = RequestUnit。DocumentDBにおける「単位時間(秒)あたりの処理数の抽象単位」。課金に大きく影響する数値です。

1. デフォルトのインデックス設定

DocumentDBでは、インデックスはコレクションに対して設定可能です。
またコレクションを作成すると、デフォルトでは、以下のようなインデックスポリシーが設定されます。

{
  "indexingMode": "consistent",
  "automatic": true,
  "includedPaths": [
    {
      "path": "/*",
      "indexes": [
        {
          "kind": "Range",
          "dataType": "Number",
          "precision": -1
        },
        {
          "kind": "Hash",
          "dataType": "String",
          "precision": 3
        }
      ]
    }
  ],
  "excludedPaths": []
}

これは、Azureポータル上で「対象DocumentDBアカウント → DataExploere → 対象データベース → 対象コレクション → Scale & Settings」で確認することができます。

f:id:daigo-knowlbo:20170505004326p:plain

「includedPaths」はインデックス適用対象のパス、「excludedPaths」はインデックス所帯対象のパス、という意味になります。
「path: “/*"」とは、「格納されたJSONデータ要素のすべて(配下の階層を含めてすべて)に対して」という意味を持ちます。
そして ”/*" に対して、「Range」「Hash」の2種類のインデックスが設定されています。
それぞれのインデックス種類の意味は、DocumentDB インデックス作成ポリシー | Microsoft Docsに記述されており、以下が対象箇所の引用になります。

Hash:
 Hash を /prop/? (または/) に使用して、以下のクエリを効率的に処理することができます。
 SELECT FROM collection c WHERE c.prop = “value”
 Hash を /props/[]/? (または / または /props/) に使用して、以下のクエリを効率的に処理することができます。
 SELECT tag FROM collection c JOIN tag IN c.props WHERE tag = 5

Range:
 Range を /prop/? (または/) に使用して、以下のクエリを効率的に処理することができます。
 SELECT FROM collection c WHERE c.prop = “value”
 SELECT FROM collection c WHERE c.prop > 5
 SELECT FROM collection c ORDER BY c.prop

インデックスの種類にはもう1つ「Spatial」があります。これはGEOクエリーを行う為のインデックスとなります。

つまり、コレクションに対するデフォルトのインデックス設定は、一般的な「文字列比較」や「数値比較」による抽出、また並べ替えのためのインデックスが、すべてのドキュメント要素に適用されることになります。

2. 下準備

さて、本投稿では「インデックスの最適化によるRUの変動」を調べるのですが、そのためのデータを準備したいと思います。
以下のPhotoItemクラスをコレクションに保存するドキュメントエンティティとします。

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

  // タイトル
  public string Title { get; set; }

  // 「いいね」数
  public int LikeCount { get; set; }

  // 写真のURI
  public string PhotoUri { get; set; }

  // カテゴリ
  public string Category { get; set; }
}

データベース・コレクションは以下のDocumentManagerクラスのInitializeDocumentClient()メソッドで作成します。
データベース名は ExampleDB1、コレクション名は ExampleCollection1、パーティションキーとして Category を設定します。
CreateExampleDocument(PhotoItem photoItem)メソッドは、PhotoItemドキュメントを作成します。

public class DocumentManager
{
  private const string EndpointUrl = "https://rddocdb.documents.azure.com:443/";
  private const string PrimaryKey = "[プライマリキー]";

  private const string DatabaseID = "ExampleDB1";
  private const string CollectionID = "ExampleCollection1";

  private DocumentClient client;

  // データベースとコレクションを作成
  public async Task<bool> InitializeDocumentClient()
  {
    this.client = new DocumentClient(new Uri(DocumentManager.EndpointUrl), DocumentManager.PrimaryKey,
      new ConnectionPolicy
      {
        ConnectionMode = ConnectionMode.Gateway,
        ConnectionProtocol = Protocol.Https
      });

    // ExampleDB1 データベースを作成
    await this.client.CreateDatabaseIfNotExistsAsync(
      new Database { Id = DocumentManager.DatabaseID });

    // ExampleCollection1 コレクションを作成(パーティションキーはCategory)
    Collection<string> partitionPaths = new Collection<string>();
    partitionPaths.Add("/Category");
    var collection = new DocumentCollection { 
                Id = DocumentManager.CollectionID, 
                PartitionKey = new PartitionKeyDefinition() { Paths = partitionPaths } };
    collection = await this.client.CreateDocumentCollectionIfNotExistsAsync(
      UriFactory.CreateDatabaseUri(DocumentManager.DatabaseID),
      collection);

    return true;
  }
  
  // ドキュメントを作成
  public async Task<Document> CreateExampleDocument(PhotoItem photoItem)
  {
    // ドキュメント作成
    var doc = await this.client.CreateDocumentAsync(
      UriFactory.CreateDocumentCollectionUri(DocumentManager.DatabaseID, DocumentManager.CollectionID),
      photoItem);
    
    return doc;
  }

  ...省略

}

以下のコードで DocumentManagerクラス を呼び出してデータベース・コレクション・10,000件のドキュメントを作成します。

// DocumentManagerクラスを呼び出してデータベースを初期化
Random random = new Random();

var manager = new DocumentManager();
var initResult = await manager.InitializeDocumentClient();

string[] categories = { "sky", "sea", "mountain", "people" };

for (int i = 0; i < 10000; i++)
{
  PhotoItem item = new PhotoItem()
  {
    Id = i.ToString(),
    Title = string.Format("SampleTitle{0}", i.ToString()),
    LikeCount = random.Next(1000),
    PhotoUri = string.Format("https://xxxxxxx/{0}", i.ToString()),
    Category = categories[i%4]
  };
  var ret = await manager.CreateExampleDocument(item);
  System.Threading.Thread.Sleep(50); // 429しないように緩やかに追加する
  Console.WriteLine(i);
}

3. デフォルト状態での書き込みと読み込み

デフォルトのインデックス状態で書き込みと読み込みを確認してみます。

書き込み

10件のドキュメントを書き込んでみます。

Random random = new Random();

var manager = new DocumentManager();
var initResult = await manager.InitializeDocumentClient();

{
  string[] categories = { "sky", "sea", "mountain", "people" };
  for (int id = 10000; id < 10010; id++)
  {

    PhotoItem item = new PhotoItem()
    {
      Id = id.ToString(),
      Title = string.Format("SampleTitle{0}", id.ToString()),
      LikeCount = random.Next(1000),
      PhotoUri = string.Format("https://xxxxxxx/{0}", id.ToString()),
      Category = categories[id % 4]
    };
    var ret = await manager.CreateExampleDocument(item);
  }
}

上記ソースにより送信されたHTTPリクエストは以下のようになります(ここでは1件のみ抽出。続けて実施した10件の書き込みのRUは同一であった為)。

POST https://rddocdb-japaneast.documents.azure.com/dbs/ExampleDB1/colls/ExampleCollection1/docs HTTP/1.1
x-ms-documentdb-partitionkey: ["sea"]
x-ms-date: Thu, 04 May 2017 16:46:29 GMT
authorization: type%3dmaster%26ver%3d1.0%26sig%3deHQp5YAx4oaQAkFvxYn0vh9sMbLFqPliTss6TqfrT1Y%3d
x-ms-session-token: 0:10043
Cache-Control: no-cache
x-ms-consistency-level: Session
User-Agent: documentdb-dotnet-sdk/1.13.2 Host/32-bit MicrosoftWindowsNT/6.2.9200.0
x-ms-version: 2017-01-19
Accept: application/json
Host: rddocdb-japaneast.documents.azure.com
Content-Length: 109
Expect: 100-continue

{"id":"10001","Title":"SampleTitle10001","LikeCount":469,"PhotoUri":"https://xxxxxxx/10001","Category":"sea"}

そして、HTTPレスポンスは以下の通りです。

HTTP/1.1 201 Created
Cache-Control: no-store, no-cache
Pragma: no-cache
Transfer-Encoding: chunked
Content-Type: application/json
Server: Microsoft-HTTPAPI/2.0
Strict-Transport-Security: max-age=31536000
x-ms-last-state-change-utc: Thu, 04 May 2017 08:40:05.215 GMT
etag: "00003133-0000-0000-0000-590b5ae50000"
x-ms-resource-quota: documentSize=10240;documentsSize=10485760;documentsCount=-1;collectionSize=10485760;
x-ms-resource-usage: documentSize=6;documentsSize=3912;documentsCount=10031;collectionSize=6560;
x-ms-schemaversion: 1.3
x-ms-alt-content-path: dbs/ExampleDB1/colls/ExampleCollection1
x-ms-content-path: FvRbAKerSQA=
x-ms-quorum-acked-lsn: 10043
x-ms-current-write-quorum: 3
x-ms-current-replica-set-size: 4
x-ms-xp-role: 1
x-ms-request-charge: 6.1
x-ms-serviceversion: version=1.13.76.2
x-ms-activity-id: 6f53bfbf-d4b1-4ad4-9bf1-b4ce7367636b
x-ms-session-token: 0:10044
x-ms-gatewayversion: version=1.13.76.2
Date: Thu, 04 May 2017 16:46:28 GMT

...省略...

消費RUは・・・「x-ms-request-charge: 6.1」。
1件の書き込みRUは 6.1 でした。

読み込み

ドキュメントを読み込みを行います。
DocumentManagerクラスに検索用メソッドQueryPhotoItems()を追加します。
QueryPhotoItems()はかなり固定的な実装で、LikeCountが100より大きく、Categoryが sky のドキュメントを50件取得します。

public class DocumentManager {
 ...省略
  public async Task<List<PhotoItem>> QueryPhotoItems()
  {
    List<PhotoItem> result = null;

    var feedOption = new FeedOptions() { MaxItemCount = 50 };

    var query = this.client.CreateDocumentQuery<PhotoItem>(
      UriFactory.CreateDocumentCollectionUri(DocumentManager.DatabaseID, DocumentManager.CollectionID), feedOption)
      .Where(i => i.LikeCount > 100 && 
             i.Category == "sky")
      .Take(50);
    result = query.ToList();

    return result;
  }
}

DocumentManager.QueryPhotoItems()を呼び出す実装は以下の通り。

var manager = new DocumentManager();
var initResult = await manager.InitializeDocumentClient();
// LikeCountが>100のドキュメントを50件検索
var result = await manager.QueryPhotoItems();

上記で実行されるHTTPリクエストは以下の通りです。

POST https://rddocdb-japaneast.documents.azure.com/dbs/ExampleDB1/colls/ExampleCollection1/docs HTTP/1.1
x-ms-continuation: 
x-ms-documentdb-isquery: True
x-ms-max-item-count: 50
x-ms-documentdb-query-enablecrosspartition: False
x-ms-documentdb-query-iscontinuationexpected: False
x-ms-date: Thu, 04 May 2017 16:59:15 GMT
authorization: type%3dmaster%26ver%3d1.0%26sig%3dKF%2fbc5aVHlTffYtoHF4k5qzALl727%2bNdHlERf3plj7U%3d
Cache-Control: no-cache
x-ms-consistency-level: Session
User-Agent: documentdb-dotnet-sdk/1.13.2 Host/32-bit MicrosoftWindowsNT/6.2.9200.0
x-ms-version: 2017-01-19
Accept: application/json
Content-Type: application/query+json
Host: rddocdb-japaneast.documents.azure.com
Content-Length: 109
Expect: 100-continue

{"query":"SELECT TOP 50 * FROM root WHERE ((root[\"LikeCount\"] > 100) AND (root[\"Category\"] = \"sky\")) "}

HTTPレスポンスは以下の通りです。

HTTP/1.1 200 Ok
Cache-Control: no-store, no-cache
Pragma: no-cache
Transfer-Encoding: chunked
Content-Type: application/json
Server: Microsoft-HTTPAPI/2.0
Strict-Transport-Security: max-age=31536000
x-ms-last-state-change-utc: Thu, 04 May 2017 08:40:13.755 GMT
x-ms-resource-quota: documentSize=10240;documentsSize=10485760;documentsCount=-1;collectionSize=10485760;
x-ms-resource-usage: documentSize=6;documentsSize=4298;documentsCount=10040;collectionSize=6964;
x-ms-item-count: 50
x-ms-schemaversion: 1.3
x-ms-alt-content-path: dbs/ExampleDB1/colls/ExampleCollection1
x-ms-content-path: FvRbAKerSQA=
x-ms-xp-role: 2
x-ms-request-charge: 20.32
x-ms-serviceversion: version=1.13.76.2
x-ms-activity-id: 2f1f663c-09e2-4913-b20d-8ef5ac3d63c2
x-ms-session-token: 0:10052
x-ms-gatewayversion: version=1.13.76.2
Date: Thu, 04 May 2017 16:59:15 GMT

...省略...

x-ms-request-charge: 20.32。
消費RUは 20.32 でした。

4. LikeCount/Categoryのみのインデックス設定で書き込みと読み込み

インデックスを以下のように変更します。
LikeCount / Categoryのみにインデックスを適用するようにカスタマイズします。

{
  "indexingMode": "consistent",
  "automatic": true,
  "includedPaths": [
    {
      "path": "/LikeCount/*",
      "indexes": [
        {
          "kind": "Range",
          "dataType": "Number",
          "precision": -1
        }
      ]
    },
    {
      "path": "/Category/*",
      "indexes": [
        {
          "kind": "Hash",
          "dataType": "String",
          "precision": 3
        }
      ]
    }
  ],
  "excludedPaths": [
    {
      "path": "/*"
    }
  ]
}

設定はAzureポータルで「設定 → インデックス作成ポリシー」から変更することができます。

f:id:daigo-knowlbo:20170505020619p:plain

書き込み

先程と同様に10件のドキュメントを書き込んでみます。
HTTPリクエストは以下の通りです。

POST https://rddocdb-japaneast.documents.azure.com/dbs/ExampleDB1/colls/ExampleCollection1/docs HTTP/1.1
x-ms-documentdb-partitionkey: ["mountain"]
x-ms-date: Thu, 04 May 2017 17:03:09 GMT
authorization: type%3dmaster%26ver%3d1.0%26sig%3deHdnuMnm8pSUdfK4T0ItZPjQzGvmyzqBOEExfbDCuUw%3d
Cache-Control: no-cache
x-ms-consistency-level: Session
User-Agent: documentdb-dotnet-sdk/1.13.2 Host/32-bit MicrosoftWindowsNT/6.2.9200.0
x-ms-version: 2017-01-19
Accept: application/json
Host: rddocdb-japaneast.documents.azure.com
Content-Length: 114
Expect: 100-continue

{"id":"10010","Title":"SampleTitle10010","LikeCount":782,"PhotoUri":"https://xxxxxxx/10010","Category":"mountain"}

HTTPレスポンスは以下の通りです。

HTTP/1.1 201 Created
Cache-Control: no-store, no-cache
Pragma: no-cache
Transfer-Encoding: chunked
Content-Type: application/json
Server: Microsoft-HTTPAPI/2.0
Strict-Transport-Security: max-age=31536000
x-ms-last-state-change-utc: Thu, 04 May 2017 08:40:05.215 GMT
etag: "00004e33-0000-0000-0000-590b5ecd0000"
x-ms-resource-quota: documentSize=10240;documentsSize=10485760;documentsCount=-1;collectionSize=10485760;
x-ms-resource-usage: documentSize=6;documentsSize=4317;documentsCount=10040;collectionSize=6965;
x-ms-schemaversion: 1.3
x-ms-alt-content-path: dbs/ExampleDB1/colls/ExampleCollection1
x-ms-content-path: FvRbAKerSQA=
x-ms-quorum-acked-lsn: 10053
x-ms-current-write-quorum: 3
x-ms-current-replica-set-size: 4
x-ms-xp-role: 1
x-ms-request-charge: 5.52
x-ms-serviceversion: version=1.13.76.2
x-ms-activity-id: fedec436-d43f-479c-b8c7-8ce095d9dd07
x-ms-session-token: 0:10054
x-ms-gatewayversion: version=1.13.76.2
Date: Thu, 04 May 2017 17:03:08 GMT

...省略...

消費RUは・・・「x-ms-request-charge: 5.52」。
1件の書き込みRUは 5.52 でした。
デフォルトインデックス状態の 6.1 よりも小さな値になりました。

読み込み

ドキュメントを読み込みを行います。
先程と同様の読み込みを行います。

HTTPリクエストは以下の通りです。

POST https://rddocdb-japaneast.documents.azure.com/dbs/ExampleDB1/colls/ExampleCollection1/docs HTTP/1.1
x-ms-continuation: 
x-ms-documentdb-isquery: True
x-ms-max-item-count: 50
x-ms-documentdb-query-enablecrosspartition: False
x-ms-documentdb-query-iscontinuationexpected: False
x-ms-date: Thu, 04 May 2017 17:10:13 GMT
authorization: type%3dmaster%26ver%3d1.0%26sig%3dvO5eIEtwI7FZ3%2bD8d8ezEIvH3Hp9EuY1EJ8ZR0Rm2WE%3d
Cache-Control: no-cache
x-ms-consistency-level: Session
User-Agent: documentdb-dotnet-sdk/1.13.2 Host/32-bit MicrosoftWindowsNT/6.2.9200.0
x-ms-version: 2017-01-19
Accept: application/json
Content-Type: application/query+json
Host: rddocdb-japaneast.documents.azure.com
Content-Length: 109
Expect: 100-continue

{"query":"SELECT TOP 50 * FROM root WHERE ((root[\"LikeCount\"] > 100) AND (root[\"Category\"] = \"sky\")) "}

HTTPレスポンスは以下の通りです。

HTTP/1.1 200 Ok
Cache-Control: no-store, no-cache
Pragma: no-cache
Transfer-Encoding: chunked
Content-Type: application/json
Server: Microsoft-HTTPAPI/2.0
Strict-Transport-Security: max-age=31536000
x-ms-last-state-change-utc: Thu, 04 May 2017 08:40:13.755 GMT
x-ms-resource-quota: documentSize=10240;documentsSize=10485760;documentsCount=-1;collectionSize=10485760;
x-ms-resource-usage: documentSize=6;documentsSize=3929;documentsCount=10050;collectionSize=6597;
x-ms-item-count: 50
x-ms-schemaversion: 1.3
x-ms-alt-content-path: dbs/ExampleDB1/colls/ExampleCollection1
x-ms-content-path: FvRbAKerSQA=
x-ms-xp-role: 2
x-ms-request-charge: 20.32
x-ms-serviceversion: version=1.13.76.2
x-ms-activity-id: 2be70cc0-d86c-40ce-8f72-a2542c954ad9
x-ms-session-token: 0:10063
x-ms-gatewayversion: version=1.13.76.2
Date: Thu, 04 May 2017 17:10:13 GMT

...省略...

消費RUは 20.32 でした。
検索条件(LikeCount・Category)が、デフォルトインデックス時も、その後のカスタムインデックス時も共に対象となっている為に、読み込み時の消費RUが変化しませんでした。

4. 読み込みインデックスの効きを確認する

前述の確認では デフォルトインデックス時・カスタムインデックス時 共にインデックス対象の項目で検索してしまっていました。
検索条件を以下のようにした場合はどうでしょうか?

var query = this.client.CreateDocumentQuery<PhotoItem>(
  UriFactory.CreateDocumentCollectionUri(DocumentManager.DatabaseID, DocumentManager.CollectionID), feedOption)
  .Where(i => i.LikeCount > minLikeCount && 
              i.Category == "sky" && 
              i.Title == "SampleTitle0")
  .Take(50);

Title項目も検索対象としました(このクエリー条件は現実のビジネス的にはちょっと変な条件指定ですが・・・技術的検証としてとらえてください)。
Titleは「デフォルトインデックス時にはインデックス対象」「カスタムインデックス時にはインデックス対象外」となる項目です。

実施結果は以下のようになります。

デフォルトインデックス時(インデックス対象) : x-ms-request-charge: 5.96
カスタムインデックス時(インデックス対象外) : x-ms-request-charge: 204.98

RUに大きな違いが発生しました。
インデックス外の項目での検索の負荷の高さを確認することができます。

まとめ

DocumentDBではデフォルト状態で「すべての項目に対して Hash と Range のインデックスが設定されている」。
GEOクエリーを行わなければ、何もしないでもある程度いい感じに動いてくれる。
インデックスの最適化で消費RUの最適化が図れる。ただし、適切に実施しないと、消費RUに大きな差が生まれるので注意しなければならない。
といった感じでしょうか。

DocumentDBの(マスター)キーとauthorizationトークンについて

最近 DocumentDB について調べたり、使ったりしております。
DocumentDB は (たしか)2015年にGAしたと思うのですが、GAから日が経っているのに、想像したよりもネット上のブログなどでの資料が少ない気がしています(特に日本語)。
一方 本家のドキュメントは日本語版も含めかなりしっかりしている印象です。

まあ、大抵のエンプラ系システムであれば、NoSQLなんて使わず従来通りのSQL Server(もしくはSQL Database)でOKですからね。
普通の開発現場では、ちょー早くて、無制限にスケール可能で、でもデータ構造設計やパーティション分割やトランザクションや検索にコツが必要な DocumentDB(NoSQL) を欲するケースは少ないのでしょう・・・

でも、私個人的には、DocumentDBを含むクラウドネイティブな技術が好きなんですよね^^

と長くなりましたが本題へ・・・

(マスター)キーとauthorizationトークンの関係

このお話、DocumentDBをREST APIから使用している(使用したことがある)方は分かっていると思いますが、ライブラリ経由でしか使ったことがない方の場合は認識できていないかもしれない、という内容のお話です。
だからDocumentDBを利用した開発においては必須というわけではありません。

DocumentDBを利用する場合、以下の2パターンの方法があります。

  • REST APIを直に呼び出す
  • 各言語用ライブラリを使用する

大抵、後者の各言語用ライブラリを使う事と思います。
Visual Studio & .NET だったらNugetで「Microsoft.Azure.DocumentDB(.NET CoreならMicrosoft.Azure.DocumentDB.Core)」を追加するだけですね。
ライブラリはバックエンドでREST APIを呼び出してくれます。接続プロトコル(ConnectionPolicy.ConnectionProtocol)を HTTPS にしていれば、Fiddlerなんかでその通信内容を確認することができます。

で、DocumentDBに接続する場合には、DocumentClientクラスを以下のようにインスタンス化します。

var client = new DocumentClient(uri, authKey);

認証・認可のために第2引数に認証キー(authKey)を渡します(第1引数uriは、利用するDocumentDBアカウントのURIです)。
インスタンス化した DocumentClient に対して、CreateDocumentQuery()だったりCreateStoredProcedureQuery()だったりのメソッド呼び出しを行うことで対象のDocumentDBに対する操作を行うことができます。
「認証キー(authKey)」というのは、Azureポータルで確認可能なプライマリキー(セカンダリキー)です。
以下の画面キャプチャ。

f:id:daigo-knowlbo:20170504125630p:plain

ではDocumentClientがDocumentDBに対して操作を行う際にはどのような通信を行っているのか確認してみます。
まず、以下のようなDocumentDBの操作を行うクラスを用意します(DocumentManagerクラス)。

public class DocumentManager {
  private const string EndpointUrl = "https://rddocdb.documents.azure.com:443/";

  private const string PrimaryKey = "[(マスター)プライマリキー]";
  
  private const string DatabaseID = "ExampleDB1";
  private const string CollectionID = "ExampleCollection1";
  
  private DocumentClient client;
  
  public async Task<bool> Init() {
    // DocumentClientオブジェクト 作成
    this.client = new DocumentClient(new Uri(DocumentManager.EndpointUrl), DocumentManager.PrimaryKey,
      new ConnectionPolicy
      {
        ConnectionMode = ConnectionMode.Gateway,
        ConnectionProtocol = Protocol.Https
      });
    
    // ExampleDB1 作成
    var db = await this.client.CreateDatabaseIfNotExistsAsync(
      new Database { Id = DocumentManager.DatabaseID });
    
    // ExampleCollection1 作成
    var collection = await this.client.CreateDocumentCollectionIfNotExistsAsync(
      UriFactory.CreateDatabaseUri(DocumentManager.DatabaseID),
      collection);
    
    return true;
  }
  
  public async Task<bool> ReadDocumentCollection() {
    // コレクション情報を読み込む
    var collection = await this.client.ReadDocumentCollectionAsync(
      UriFactory.CreateCollectionUri(DocumentManager.DatabaseID, DocumentManager.CollectionID));

    return true;
  }
}

Init()メソッドは、以下の処理を行います。

  • DocumentClientオブジェクトを作成しprivateメンバーに保持
  • ExampleDB1データベースを作成
  • ExampleCollection1コレクションを作成

ReadDocumentCollection()メソッドは、Init()で作成されたExampleCollection1コレクション情報を読み取ります。

そして、以下のようにdocumentManagerを呼び出しましょう。

var documentManager = new DocumentManager();
var initResult = await documentManager.Init();
var readResult = await documentManager.ReadDocumentCollection();

最後の ReadDocumentCollection() 呼び出しに注目します。
Fiddlerで抜き取ったHTTPS通信は以下の通りです。

f:id:daigo-knowlbo:20170504133809p:plain

「/dbs/ExampleDB1/colls/ExampleCollection1」にGETリクエストを行っています。
つまり↓↓↓のREST APIが呼び出されています。
Get a Collection | Microsoft Docs
HTTP GETのリクエストヘッダで注目すべきは authorization です。
DocumentClientオブジェクト作成時に渡した認証キー(プライマリキー)を元にして authorization 値が生成されています。
厳密には認証キー(プライマリキー)を含む以下の要素から生成されています。

  • HTTP Verb(GETとかPOSTとか)
  • リソースタイプ(CollectionとかDocumentとかのアクセスの対象リソースタイプ)
  • リソースリンク(こんなの・・/dbs/ExampleDB1/colls/ExampleCollection1)
  • 日時(トークン生成日)
  • キー
  • キーのタイプ(masterとかresource)
  • トークンバージョン

日時は注意が必要で、トークン生成時に指定した日時と HTTPヘッダ x-ms-date 値がマッチしている必要があります。

上記からauthorizationトークンを生成するロジックは Access Control on DocumentDB Resources | Microsoft Docs に書かれておりまして、コードの引用が以下になります。

// authorizationトークンを作成
static string GenerateAuthToken(
  string verb, 
  string resourceType, 
  string resourceLink, 
  string date, 
  string key, 
  string keyType, 
  string tokenVersion)
{
  var hmacSha256 = new System.Security.Cryptography.HMACSHA256 { Key = Convert.FromBase64String(key) };

  verb = verb ?? "";
  resourceType = resourceType ?? "";
  resourceLink = resourceLink ?? "";

  string payLoad = string.Format(System.Globalization.CultureInfo.InvariantCulture, "{0}\n{1}\n{2}\n{3}\n{4}\n",
      verb.ToLowerInvariant(),
      resourceType.ToLowerInvariant(),
      resourceLink,
      date.ToLowerInvariant(),
      ""
  );

  byte[] hashPayLoad = hmacSha256.ComputeHash(System.Text.Encoding.UTF8.GetBytes(payLoad));
  string signature = Convert.ToBase64String(hashPayLoad);

  return System.Web.HttpUtility.UrlEncode(String.Format(System.Globalization.CultureInfo.InvariantCulture, "type={0}&ver={1}&sig={2}",
    keyType,
    tokenVersion,
    signature));
}

以下のようなコードで認証トークンを生成することができます。

var authorizationToken = 
  GenerateAuthToken(
    "get", 
    "colls", 
    "dbs/ExampleDB1/colls/ExampleCollection1", 
    "Tue, 02 May 2017 10:20:01 GMT", 
    "[プライマリキー]", 
    "master", 
    "1.0");

認証トークン生成して使ってみる

では認証トークンを生成して実際にREST呼び出ししてみましょう。
Fiddlerを使ってHTTPリクエストを投げたいと思います。

1.正常なケース

以下のリクエストを行います。
トークン生成に使用した 日時 と x-ms-date は同一でないといけない点には注意。
f:id:daigo-knowlbo:20170504141751p:plain

いい感じにレスポンスが返ってきました。
f:id:daigo-knowlbo:20170504141832p:plain

2.不正なauthorizationトークンを利用したケース

リクエスト内容とマッチしないauthorizationトークンを使ってリクエストを行います。
f:id:daigo-knowlbo:20170504141857p:plain

401 Unauthorized が返ってきます。
f:id:daigo-knowlbo:20170504141923p:plain

3.期限切れのauthorizationトークンを利用したケース

authorizationトークンは有効期限があります。
次は、有効期限切れになったauthorizationトークンでリクエストを行ってみます。
f:id:daigo-knowlbo:20170504141947p:plain

403 Forbidden が返ってきます。 f:id:daigo-knowlbo:20170504142012p:plain

bodyを見ると親切に以下のようなメッセージが書かれています。

{
  "code":"Forbidden",
  "message":"The authorization token is not valid at the current time. 
             Please create another token and retry 
             (token start time: Thu, 04 May 2017 00:57:01 GMT, 
              token expiry time: Thu, 04 May 2017 01:12:01 GMT, 
              current server time: Thu, 04 May 2017 05:04:48 GMT).\r\n
              ActivityId: f7c026e7-ddea-4c89-9331-4c9b24d50d9e"
}

つまり・・・↓↓↓と言っていますね。DocumentDB、超親切!!

そのトークンは現時刻では無効だよ。
別のトークン作ってリトライしてね。
そのトークンの開始時刻は「Thu, 04 May 2017 00:57:01 GMT」。無効になるのは「Thu, 04 May 2017 01:12:01 GMT」、今のサーバー時刻は「Thu, 04 May 2017 05:04:48 GMT」。

うん、つまりauthorizationトークンの有効期限は15分ってことですね。

まとめ

内容的に まとめ ってこともないのですが、いつも思うことはライブラリにラップされた内容だけでなくその裏側の本当の姿まで理解しておく事は重要だと思います。
そうすると、何か問題が起きた時に色々解決の糸口への道のりが短くなるのだと思っています。