Azure Cosmos DB入門(4)

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

ryuichi111std.hatenablog.com

4 Cosmos DBプログラミング ~ DocumentDB編(後編)

前回の投稿に引き続き、「Cosmos DBプログラミング ~ DocumentDB編(後編)」と題して投稿いたします。
LINQSQLによるクエリー操作、INSERT/UPDATEについては前回説明しました。
今回は以下について説明を進めます。

前回作成した GroupwareDBデータベース -> RoomReservationsコレクション(/Roomパーティションキー設定、2500RU) のサンプルコレクションを引き続き使用して説明を進めます。

4.1 サーバーサイド(データベースサイド)ロジック

SQL Serverには、データベースサイドで実行されるロジックとして「ストアドプロシージャ」「トリガー」というものがありました。
これらを作成する場合、「Transact-SQL」と呼ばれる言語でロジックを記述しました。Transact-SQLは、SQL言語に変数定義や条件分岐ステートメントなどの、ロジックを記述するのに必要な言語機能を追加したものです。
Cosmos DBにおいてもデータベースサイドのロジックを記述することが可能です。詳細は順に後述しますが「ストアドプロシージャ」「トリガー」「ユーザー定義関数(UDF)」というものを作成することが出来、言語として「JavaScript」を使用します。
Cosmos DBに保存されるデータ形式は「JSON形式」を基本としているため、このデータを扱うのにJavaScriptは相性が良いものとなっています。

また「ストアドプロシージャ」「トリガー」「ユーザー定義関数(UDF)」は、「コレクション」に対して登録します。

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

つまり、これらがアクセスする対象のデータ(ドキュメント)のスコープは「単一コレクション」となります。

4.2 トランザクション

データベース処理において「トランザクション」というものは重要な考えです。
従来の一般的なRDBを用いた業務アプリケーションでは、トランザクション機能は必須でした。しかし NoSQL の世界においてはトランザクション機能 は非常に限定的です。そして、また、トランザクションな処理の実装手法自体が異なる考え方を持つ必要があります。
RDBにおいては「データベースシステム内にトランザクション処理を隠蔽する」ことが可能なのに対して、NoSQLでは「”処理毎のシステム要件”と”それを満たす為のアプリケーション実装によるトランザクション”でデータの一貫性を保つ」というような思想の転換が必要になるケースが存在します。

4.2.1 Cosmos DBでのトランザクション

Cosmos DBでは「ストアドプロシージャ」「トリガー」内の処理においてACIDなトランザクションを保証しています。
単一のストアドプロシージャ内において、複数の更新処理を行う場合、処理の途中で例外が発生したら「すべての更新処理はロールバック」されます。ストアドプロシージャがすべて正常終了した場合にはコミットが行われいます。
ロールバック・コミットは、ストアドプロシージャ内で例外が発生したか、正常終了したかにより暗黙的に実行されるため、ユーザーコードで明示的な処理を記述する必要はありません。

注意点として「トランザクション処理が可能なドキュメント範囲」というものがあります。
Cosmos DBが提供するトランザクションは、RDBのような「複数のテーブルを跨る更新におけるトランザクション」のような高レベルのものではありません。

4.3 ストアドプロシージャ

ストアドプロシージャはJavaScriptで記述することができます。
そして、DocumentDBに対して「ドキュメントの作成」「ドキュメントの更新」等々の操作を行うために、JavaScriptのライブラリが用意されています。
以下が「DocumentDB server side JavaScript SDK」の公式のドキュメントになります。

JSDoc: Index

例えば「ドキュメントの作成」は・・・
Collectionオブジェクトの・・・

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

createDocument()ファンクションが該当します。

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

同じドキュメントデータモデルに対する操作なので、C#クラスライブラリと同じ感覚で対象のオブジェクトやファンクションを見つけることができるのではないかと思います。
ただし、言語仕様(や文化)の違いから以下のような相違点があります。

  • メソッド名が「C# = CreateDocumentAsync()」に対して「JavaScript = createDocument()」
  • 非同期に対する言語アプローチの相違から「C# = CreateDocumentAsync()」に対して「JavaScript = CreateDocument(link, doc, options, callback function)」

4.3.1 Helloストアドプロシージャ

では「Helloストアドプロシージャ」から始めたいと思います。
ストアドプロシージャを作成する方法は何通りかあります。

1つの方法として、Azureポータルの「スクリプト エクスプローラ」から作成することもできます。
ただし 2017/6/3現在 ポータル上の(おそらく)バグにより、新規作成は正常に動作しますが、更新においてエラーが発生します。削除して再作成の手順を踏む必要があります。

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

もう1つの方法としてプログラムからストアドプロシージャを作成する方法があります。
本稿ではこちらをベースとして説明を進めます。

(1)JavaScriptファイルを作成

プログラム内にストアドプロシージャ実装のJavaScriptをハードコードすることもできますが、ここでは.jsファイルに切り出すことにします。

リスト1 helloStoredProcedure.js

function helloStoredProcedure(yourName) {
  var context = getContext();
  var response = context.getResponse();
  
  response.setBody("Hello!! " + yourName);
}
  • function helloStoredProcedure(yourName)
    helloStoredProcedureファンクションは引数を1つ受け取ります。

  • getContext()ファンクション
    getContext()ファンクション呼び出しにより、現在のデータベース コレクションに対するアクセスを行う機能を提供する Contextオブジェクト を取得することができます。ほとんどのストアドプロシージャにおいて、まずContextオブジェクトの取得を行います。

  • context.getResponse()ファンクション
    現在のコンテキストにおける Responseオブジェクト を取得することができます。

  • response.setBody()
    ResponseオブジェクトのsetBody()ファンクションによって、呼び出し元への戻り値を設定することができます。
    ここでは、引数 yourName に対する Hello!! の挨拶文字列を返却しています。

(2)JavaScriptファンクションの登録

GroupwareDBデータベースのRoomReservationsコレクション に対してhelloStoredProcedure.jsを登録(作成)します。
DocumentDbManagerクラスに以下のメソッドを追加します(リスト2)。

リスト2 DocumentDbManager.cs
public async Task<bool> CreateStoredProcedure(string scriptFileName)
{
  string procedureId = Path.GetFileNameWithoutExtension(scriptFileName);

  var storedProcedure = new StoredProcedure
  {
    Id = procedureId,
    Body = File.ReadAllText(scriptFileName)
  };

  Uri collectionUrl =
    UriFactory.CreateDocumentCollectionUri(
      DocumentDbManager.DatabaseId,
      DocumentDbManager.CollectionId);

  // 存在したら削除
  var currentStoredProcedure = 
    this.client.CreateStoredProcedureQuery(collectionUrl)
    .Where(sp => sp.Id == procedureId).AsEnumerable().FirstOrDefault();
  if (currentStoredProcedure != null)
  {
    var sp = await this.client.DeleteStoredProcedureAsync(currentStoredProcedure.SelfLink);
  }

  // 作成
  storedProcedure = await client.CreateStoredProcedureAsync(collectionUrl, storedProcedure);

  return true;
}
  • scriptFileName引数
    汎用的なメソッドとする為、ストアドプロシージャを実装したjsファイル名を引数で受け取ることとします。

  • StoredProcedureオブジェクト
    作成するストアドプロシージャの情報を「StoredProcedureオブジェクト」として作成します。
    Idプロパティは、ストアドプロシージャの識別子です。呼び出す際には このId で呼び出しを行います。ここでは、.jsファイルの拡張子を除いたファイル名、つまり「helloStoredProcedure」としています。
    Bodyプロパティは、stringのJavaScript Function定義です。ここでは、jsファイルをFile.ReadAllText()で読み込んだデータを設定しています。

  • CreateStoredProcedureQuery() / DeleteStoredProcedureAsync()
    CreateHelloStoredProcedure()メソッドの実装は、繰り返し呼び出してもエラーが出ないように、すでに同IDのストアドプロシージャが存在したら削除する処理を実装しています。
    CreateStoredProcedureQuery()が既存のストアドプロシージャを取得する処理、DeleteStoredProcedureAsync()が既存のストアドプロシージャを削除する処理です。

  • CreateStoredProcedureAsync()
    引数により「作成対象のコレクション」「StoredProcedureオブジェクト」を指定することでストアドプロシージャを作成します。
    2つの引数から、どこのコレクションに対して、どんなIdで、どんな実装(function)を、という各要素が示されたことが分かります。

(3)ストアドプロシージャの呼び出し

helloStoredProcedureストアドプロシージャを呼び出す処理を実装します。
DocumentDbManagerクラスにCallHelloStoredProcedure()メソッドを追加します。

リスト3 DocumentDbManager.cs
public async Task<string> CallHelloStoredProcedure(string yourName)
{
  RequestOptions options = new RequestOptions();
  options.PartitionKey = new PartitionKey("第1会議室");
  var result = await this.client.ExecuteStoredProcedureAsync<string>(
    UriFactory.CreateStoredProcedureUri(
      DocumentDbManager.DatabaseId,
      DocumentDbManager.CollectionId,
      "helloStoredProcedure"),
    options,
    yourName);

  return result;
}
/* パーティションキーの無いコレクションに対する呼び出しの場合
public async Task<string> CallHelloStoredProcedure(string yourName)
{
  var result = await this.client.ExecuteStoredProcedureAsync<string>(
    UriFactory.CreateStoredProcedureUri(
      DocumentDbManager.DatabaseId, 
      DocumentDbManager.CollectionId, 
      "helloStoredProcedure"), 
    yourName);

  return result;
}
*/
  • ExecuteStoredProcedureAsync()
    DocumentClient.ExecuteStoredProcedureAsync()によりストアドプロシージャを呼び出すことができます。
    第1引数にストアドプロシージャを表すURIを指定します。
    今回のサンプルで使用しているRoomReservationsコレクションはパーティショニングを行っています(パーティションキー「/Room」)。
    パーティショニングが有効な場合、RequestOptions.PartitionKeyにより明示的に実行対象のパーティションを指定する必要があります。
    パーティショニングされていないコレクションの場合は、コメントアウトされているシンプルな実装が可能です。
    ストアドプロシージャへの引数「yourNameパラメータ」は第3引数として指定しています。

  • ストアドプロシージャの戻り値
    ストアドプロシージャの実行結果は response.setBody() で設定されています。
    これはExecuteStoredProcedureAsync()の戻り値として取得することができます。

(4)実行

では、ストアドプロシージャの作成&実行を行うコードを実装します。
helloStoredProcedure.jsはVSプロジェクトフォルダ配下に保存されている想定です。

リスト4 Program.cs
var manager = new DocumentDbManager();

var createResult = await manager.CreateStoredProcedure(@"..\..\\helloStoredProcedure.js");
var callResult = await manager.CallHelloStoredProcedure("Ryuichi Daigo");

Console.WriteLine(callResult);
実行結果

Hello!! Ryuichi Daigo

4.3.2 期間指定で予約を削除するストアドプロシージャ

では次に、期間を指定してRoomReservationドキュメントを削除するストアドプロシージャを作成します。
あらためてRoomReservationsコレクションに保存されているドキュメントスキーマを確認しておきましょう。

 {
    "id": "0000000012",
    "Room": "第1会議室",
    "Title": "進捗会議(06/05回)",
    "ReservedUserId": "udagawa",
    "ReservedUserName": "宇田川慎介",
    "Start": "2017-06-05T10:00:00.0000000",
    "End": "2017-06-05T11:00:00.0000000",
    "AssignMembers": [
      {
        "UserId": "sugawara",
        "UserName": "菅原和歌子"
      }
    ],
    "_rid": "ul8iAOLaYQAMAAAAAAAAAA==",
    "_self": "dbs/ul8iAA==/colls/ul8iAOLaYQA=/docs/ul8iAOLaYQAMAAAAAAAAAA==/",
    "_etag": "\"0000a366-0000-0000-0000-5932132d0000\"",
    "_attachments": "attachments/",
    "_ts": 1496453932
  },

Start / End が会議室の予約開始日時・終了日時を表しています。

(1)JavaScriptファイルを作成

JavaScriptストアドプロシージャ定義は以下の通りです。

リスト5 bulkDeleteStoredProcedure.js

function bulkDeleteStoredProcedure(fromDate, toDate) {
  var context = getContext();
  var collection = context.getCollection();
  var response = context.getResponse();
  
  var filterQuery = 'SELECT * FROM Reservations r where r.Start >= "' + fromDate + '" and r.Start < "' + toDate + '"';
  var accept = collection.queryDocuments(collection.getSelfLink(), filterQuery, {},
      function (error, documents, responseOptions) {

        if (error) throw error;

        for(var i=0 ; i<documents.length ; i++){
          var accept = collection.deleteDocument(documents[i]._self, {},
            function (error, documents, responseOptions) {
              if (error) throw error;
            });
        }
        response.setBody(documents.length);
      });
}
  • fromDate / toDate引数
    bulkDeleteStoredProcedureストアドプロシージャは、2つの引数をとります。

  • collection.queryDocuments()
    CollectionオブジェクトのqueryDocuments()メソッドにより、コレクション内のドキュメントの検索を行うことができます。
    where句ではStart値のみを条件項目として指定しています。

  • collection.deleteDocument()
    条件に合致したドキュメントに対してforループで繰り返し削除処理(collection.deleteDocument())呼び出しを行っています。
    第1引数にはドキュメントを表すセルフリンクURIを指定しますが、これは document._self で取得可能です。

  • エラー処理
    queryDocuments()やdeleteDocument()の各処理はコールバックベースの実装になっています。
    エラーが発生した場合、コールバックファンクションにerrorオブジェクトが設定されます。
    リスト5ではerrorオブジェクトの有無をチェックし、エラーが発生していればthrowしています。
    何らかの理由によりエラーが発生した場合、例外のスローにより、ストアドプロシージャでの操作はすべてロールバックされます。

(2)ストアドプロシージャの呼び出し

bulkDeleteStoredProcedureストアドプロシージャを呼び出す処理を実装します。
DocumentDbManagerクラスにCallBulkDeleteStoredProcedure()メソッドを追加します。

リスト6 DocumentDbManager.cs
public async Task<int> CallBulkDeleteStoredProcedure(
  DateTime fromDate, DateTime toDate, string room)
{
  RequestOptions options = new RequestOptions();
  options.PartitionKey = new PartitionKey(room);
  var result = await this.client.ExecuteStoredProcedureAsync<int>(
    UriFactory.CreateStoredProcedureUri(
      DocumentDbManager.DatabaseId, 
      DocumentDbManager.CollectionId, "bulkDeleteStoredProcedure"), 
    options,
    fromDate, toDate);

  return result;

  /* パーティションキーの無いコレクションに対する呼び出しの場合(この場合、すべてのRoomが削除対象となる)
  var result = await this.client.ExecuteStoredProcedureAsync<int>(
    UriFactory.CreateStoredProcedureUri(
      DocumentDbManager.DatabaseId, 
      DocumentDbManager.CollectionId, "bulkDeleteStoredProcedure"), 
    fromDate, toDate);

  return result;
  */
}

helloStoredProcedure()の時もそうでしたが、ExecuteStoredProcedureAsync()でストアドプロシージャ呼び出しを行う際に、RequestOptions.PertitionKeyプロパティで処理対象のパーティションを指定する必要があります。パーティショニングされたコレクションでは、この指定がないと実行時にエラーが発生します。
helloStoredProcedure()の時にはデータ操作を行わなかったので、指定したパーティションキーによる動作への影響はありませんでしたが、今回のbulkDeleteStoredProcedure()に関しては大きな影響があります。
指定するパーティションキーは、CallBulkDeleteStoredProcedure()メソッドの引数roomを使用していますが、例えば今回のサンプルのRoomReservationsコレクションの場合「第1会議室」などになります。
そして、リスト5のJavaScriptストアドプロシージャの実装を振り返ってみましょう。
以下のようにWHERE句には Start のみを条件として指定していました。

var filterQuery = ‘SELECT * FROM Reservations r
where r.Start >= “’ + fromDate + ‘” and r.Start < “’ + toDate + ‘”’;

しかし、ストアドプロシージャ呼び出し時にパーティションキー指定を行っているため、以下と同意になります。

var filterQuery = ‘SELECT * FROM Reservations r
where r.Room = '第1会議室’
and r.Start >= “‘ + fromDate + ’” and r.Start < “‘ + toDate + ’”‘;

つまり、前述「4.1.1 Cosmos DBでのトランザクション」で示したトランザクションの範囲が強制的に適用されます。

(3)実行

では、ストアドプロシージャの作成&実行を行うコードを実装します(リスト7)。
bulkDeleteStoredProcedure.jsはVSプロジェクトフォルダ配下に保存されている想定です。

リスト7 Program.cs
var manager = new DocumentDbManager();

var createResult = manager.CreateStoredProcedure(@"..\..\\bulkDeleteStoredProcedure.js").Result;
var callResult = manager.CallBulkDeleteStoredProcedure(
  new DateTime(2017, 6, 4, 0, 0, 0), 
  new DateTime(2017, 6, 7, 23, 59, 59),
  "第1会議室").Result;

Console.WriteLine(callResult);

4.4 トリガー

トリガーはドキュメントの作成・更新・削除の前後に実行する処理をJavaScriptファンクションとして登録することができる機能です。
トリガーもストアドプロシージャと同様にコレクション配下に登録します。
また、トリガーは以下の2つの種類があります。

  • プリ・トリガー(Pre-Trigger)
    ドキュメント操作前に呼び出されます。

  • ポスト・トリガー(Post-Trigger)
    ドキュメント操作後に呼び出されます。

4.4.1 プリ・トリガー(Pre-Trigger)

プリ・トリガーは、ドキュメントの操作前に呼び出されます。
例えば以下のような処理をプリ・トリガーで実装することができます。

  • 作成されるドキュメントの内容の検証
  • 作成されるドキュメントに自動で属性を付加

プリ・トリガー処理はドキュメントの操作と同一トランザクション内で実行されます。
つまり、ドキュメント作成時に実行されるプリ・トリガー内で例外が発生した場合、ドキュメント作成処理全体がロールバックされます。

(1)プリ・トリガーの定義

.jsファイルにプリ・トリガーを実装します。
ここでは、RoomReservationドキュメント作成時に「予約開始日時(Startプロパティ値)が現在日時よりも未来であること」をチェックします。つまり、過去の日時で会議室の予約を取ることを禁止するロジックとします。
また、サンプルとして Titleプロパティ 値の変更、新たなプロパティ「AppendInfo」の追加も実装することとします。

リスト8 validatePreTrriger.js
function validatePreTrriger()
{
  var context = getContext();
  var request = context.getRequest();

  var createdDocument = request.getBody();
  
  var now = new Date(); // GMT時間
  now.setHours( now.getHours() + 9); // GMT+9hでTokyo時間
  if( Date.parse(createdDocument.Start) < Date.parse(now) )
    throw '過去の予約を取ることはできません。';

  createdDocument.Title = createdDocument.Title + ' [検証OK]';
  createdDocument.AppendInfo = 'プリトリガーで追加';
  
  request.setBody(createdDocument);
}

(2)プリ・トリガーの登録

validatePreTrriger.jsを登録する実装をDocumentDbManagerクラスに追加します(リスト9)。

リスト9 DocumentDbManager.cs
public async Task<bool> CreatePreTrigger(string scriptFileName)
{
  string triggerId = Path.GetFileNameWithoutExtension(scriptFileName);

  var trigger = new Trigger
  {
    Id = triggerId,
    TriggerType = TriggerType.Pre,
    TriggerOperation = TriggerOperation.Create,
    Body = File.ReadAllText(scriptFileName)
  };

  Uri collectionUrl =
    UriFactory.CreateDocumentCollectionUri(
      DocumentDbManager.DatabaseId,
      DocumentDbManager.CollectionId);

  // 存在したら削除
  var currentTrigger = this.client.CreateTriggerQuery(collectionUrl)
    .Where(tr => tr.Id == triggerId).AsEnumerable().FirstOrDefault();
  if (currentTrigger != null)
  {
    var sp = await this.client.DeleteTriggerAsync(currentTrigger.SelfLink);
  }

  // 作成
  trigger = await client.CreateTriggerAsync(collectionUrl, trigger);

  return true;
}
  • Triggerオブジェクト
    作成するトリガーを表すTriggerオブジェクトを作成します。
    「Id」は、コレクション内でトリガーを識別するためのユニークなIDを表します。ここでは、トリガーを定義した.jsファイル名から拡張子を取り除いたもの、つまり「validatePreTrriger」とします。
    「TriggerType」は、「Pre」もしくは「Post」となります。ここではもちろん「Pre」とします。
    「TriggerOperation」は、どのタイミングでトリガーを動作させるかを指定します。以下から選択します。

    • All
      すべての操作でトリガーを実行
    • Create
      Create操作でトリガーを実行
    • Update
      Update操作でトリガーを実行
      *Delete
      Delete操作でトリガーを実行
    • Relpace
      Relpace操作でトリガーを実行

「Body」は、トリガーの実装の実体の文字列を指定します。

  • CreateTriggerQuery() / DeleteTriggerAsync()メソッド
    CreatePreTrigger()メソッドが繰り返し呼び出されても正常に動作するように、Idから既存の登録トリガーを検索し、存在したら削除しています。

  • CreateTriggerAsync()メソッド
    Triggerオブジェクトを引数としてコレクションにトリガーを追加します。

(3)プリ・トリガーの利用

ドキュメント作成時にプリ・トリガーを起動させる予定ですが、RequestOptionでトリガーを適用することを明示的に指定する必要があります。
DocumentDbManagerクラスに、トリガーを利用したドキュメント作成メソッドを追加したものがリスト10です。

リスト10 DocumentDbManager.cs
public async Task<Document> CreateDocumentWithPreTrigger(
  RoomReservationInfo roomReservationInfo)
{
  RequestOptions options = new RequestOptions();
  options.PreTriggerInclude = new List<string>();
  options.PreTriggerInclude.Add("validatePreTrriger");

  var document =
    await this.client.CreateDocumentAsync(
    UriFactory.CreateDocumentCollectionUri(
      DocumentDbManager.DatabaseId,
      DocumentDbManager.CollectionId),
    roomReservationInfo,
    options);

  return document;
}

RequestOptions.PreTriggerIncludeに適用するプリトリガーID「validatePreTrriger」を追加しています。

(4)プリ・トリガーの利用

DocumentDbManager.CreateDocumentWithPreTrigger()でドキュメントを作成し、プリ・トリガーが実行されることを確認します(リスト11)。

リスト11 Program.cs
var manager = new DocumentDbManager();

// プリ・トリガー作成
var createResult = manager.CreatePreTrigger(@"..\..\\validatePreTrriger.js").Result;

// ドキュメントオブジェクトを作成
var assignMembers = new List<AssignMember>();
assignMembers.Add(new AssignMember() { UserId = "tanaka", UserName = "田中和夫" });
RoomReservationInfo item = new RoomReservationInfo()
{
  Id = "Pre0000000001",
  Room = "第1会議室",
  Title = "プリトリガーについて打ち合わせ",
  ReservedUserId = "daigo",
  ReservedUserName = "醍醐竜一",
  Start = new DateTime(2017, 6, 30, 18, 09, 0),
  End = new DateTime(2017, 6, 30, 19, 0, 0),
  AssignMembers = assignMembers
};

// ドキュメント登録
var callResult = manager.CreateDocumentWithPreTrigger(item).Result;

Console.WriteLine(callResult);

作成されたドキュメントをAzureポータルのクエリーエクスプローラで確認した結果が以下です(リスト12)。
Titleに [検証OK] が付加されたこと、AppendInfo 値が追加されたことを確認することができます。

リスト12
[
  {
    "id": "Pre0000000001",
    "Room": "第1会議室",
    "Title": "プリトリガーについて打ち合わせ [検証OK]",
    "ReservedUserId": "daigo",
    "ReservedUserName": "醍醐竜一",
    "Start": "2017-06-30T18:09:00",
    "End": "2017-06-30T19:00:00",
    "AssignMembers": [
      {
        "UserId": "tanaka",
        "UserName": "田中和夫"
      }
    ],
    "_etag": "\"01005af6-0000-0000-0000-5932955e0000\"",
    "AppendInfo": "プリトリガーで追加",
    "_rid": "k8tdAMsrbgA7AQAAAAAAAA==",
    "_self": "dbs/k8tdAA==/colls/k8tdAMsrbgA=/docs/k8tdAMsrbgA7AQAAAAAAAA==/",
    "_attachments": "attachments/",
    "_ts": 1496487262
  }
]

以下のリスト13のように、過去の日時を設定したドキュメント作成を行おうとした場合には、プリ・トリガーによるチェックで例外が発生し、ドキュメントの作成処理はロールバックされます。

リスト13
RoomReservationInfo item = new RoomReservationInfo()
{
  Id = "Pre0000000001",
  Room = "第1会議室",
  Title = "プリトリガーについて打ち合わせ",
  ReservedUserId = "daigo",
  ReservedUserName = "醍醐竜一",
  Start = new DateTime(2010, 1, 20, 13, 09, 0),
  End = new DateTime(2010, 1, 20, 14, 0, 0),
  AssignMembers = assignMembers
};

4.4.2 ポスト・トリガー(Post-Trigger)

ポスト・トリガーは、ドキュメントの操作後に呼び出されます。
例えば以下のような処理をポスト・トリガーで実装することができます。

  • 作成されたドキュメントについてのメタデータを更新する

ポスト・トリガー処理はドキュメントの操作と同一トランザクション内で実行されます。
つまり、ドキュメント作成後に実行されるポスト・トリガー内で例外が発生した場合、ドキュメント作成処理全体がロールバックされます。

(1)ポスト・トリガーの定義

.jsファイルにポスト・トリガーを実装します。
ここでは、RoomReservationドキュメント作成後に「同会議室(パーティション内)の予約件数をカウントアップする」処理をポスト・トリガーとして実装します(リスト14)。

リスト14 postTriggerExample.js
function postTriggerExample()
{
  var context = getContext();
  var collection = context.getCollection();
  var response = context.getResponse();
  
  var createdDocument = response.getBody();

  var filterQuery = 'SELECT * FROM root r WHERE r.id = "_metadata"';
  var accept = collection.queryDocuments(collection.getSelfLink(), filterQuery,
    function(err, documents, responseOptions)
    {
      if(err) throw err;
      
      if( documents.length == 0 ) {
        var metadata = {};
        metadata.id = '_metadata';
        metadata.Room = createdDocument.Room;
        metadata.Count = 0;
        collection.createDocument(collection.getSelfLink(), metadata, {},
        function(err, document, responseOptions)
        {
          if(err) throw err;
          
          var metadataDocument = document;
          countUpDoucumentCount(metadataDocument);
        });
      }
      else 
      {
        var metadataDocument = documents[0];
        countUpDoucumentCount(metadataDocument);

      }
    });

  if(!accept) throw "Unable to update metadata, abort";

  function countUpDoucumentCount(metadataDocument)
  {
     metadataDocument.Count += 1;
     var accept = collection.replaceDocument(metadataDocument._self,
         metadataDocument, 
         function(err, docReplaced) {
            if(err) err;
         });
     if(!accept) throw err;
  }
}

コレクション内に以下のようなメタデータドキュメントを保持することを想定しています。

[
  {
    "id": "_metadata",
    "Room": "第1会議室",
    "Count": 4,
    "_rid": "k8tdAMsrbgA3AQAAAAAAAA==",
    "_self": "dbs/k8tdAA==/colls/k8tdAMsrbgA=/docs/k8tdAMsrbgA3AQAAAAAAAA==/",
    "_etag": "\"0100a2f4-0000-0000-0000-59328d810000\"",
    "_attachments": "attachments/",
    "_ts": 1496485249
  }
]

ポスト・トリガーが呼び出されたタイミングでは、ドキュメントはすでに作成されています(未コミット状態)。
その為、Response.getBody()によってドキュメントを取得することができます。
queryDocuments()でメタデータを検索し、存在しなかったらcreateDocument()で作成しています。
countUpDoucumentCount()ファンクションの呼び出しによるメタデータのCountプロパティの値を「+1」してreplaceDocument()によるメタデータドキュメントのリプレースを行っています。

(2)ポスト・トリガーの登録

postTriggerExample.jsを登録する実装をDocumentDbManagerクラスに追加します(リスト15)。

リスト15 DocumentDbManager.cs
public async Task<bool> CreatePostTrigger(string scriptFileName)
{
  string triggerId = Path.GetFileNameWithoutExtension(scriptFileName);

  var trigger = new Trigger
  {
    Id = triggerId,
    TriggerType = TriggerType.Post,
    TriggerOperation = TriggerOperation.Create,
    Body = File.ReadAllText(scriptFileName)
  };

  Uri collectionUrl =
    UriFactory.CreateDocumentCollectionUri(
      DocumentDbManager.DatabaseId,
      DocumentDbManager.CollectionId);

  // 存在したら削除
  var currentTrigger = this.client.CreateTriggerQuery(collectionUrl)
    .Where(tr => tr.Id == triggerId).AsEnumerable().FirstOrDefault();
  if (currentTrigger != null)
  {
    var sp = await this.client.DeleteTriggerAsync(currentTrigger.SelfLink);
  }

  // 作成
  trigger = await client.CreateTriggerAsync(collectionUrl, trigger);

  return true;
}

ポスト・トリガーの登録は、先程のプリ・トリガーとほぼ同様です。異なる点は Trigger.TriggerTypeプロパティに設定する値は「TriggerType.Post」となる点のみです。

(3)ポスト・トリガーの利用

ドキュメント作成後にポスト・トリガーを起動させる予定ですが、プリ・トリガーの時と同様、RequestOptionでトリガーを適用することを明示的に指定する必要があります。
DocumentDbManagerクラスに、ポスト・トリガーを利用したドキュメント作成メソッドを追加したものがリスト16です。

リスト16 DocumentDbManager.cs
public async Task<Document> CreateDocumentWithPostTrigger(
  RoomReservationInfo roomReservationInfo)
{
  RequestOptions options = new RequestOptions();
  options.PostTriggerInclude = new List<string>();
  options.PostTriggerInclude.Add("postTriggerExample");

  var document =
    await this.client.CreateDocumentAsync(
    UriFactory.CreateDocumentCollectionUri(
      DocumentDbManager.DatabaseId,
      DocumentDbManager.CollectionId),
    roomReservationInfo,
    options);

  return document;
}

RequestOptions.PostTriggerIncludeに適用するプリトリガーID「postTriggerExample」を追加しています。

(4)ポスト・トリガーの利用

DocumentDbManager.CreateDocumentWithPostTrigger()でドキュメントを作成し、ポスト・トリガーが実行されることを確認します(リスト17)。

リスト17 Program.cs
var manager = new DocumentDbManager();

var createResult = manager.CreatePostTrigger(@"..\..\\postTriggerExample.js").Result;

var assignMembers = new List<AssignMember>();
assignMembers.Add(new AssignMember() { UserId = "sakamoto", UserName = "坂本寛子" });
RoomReservationInfo item = new RoomReservationInfo()
{
    Id = "Post0000000001",
    Room = "第1会議室",
    Title = "ポストトリガーについての打ち合わせ",
    ReservedUserId = "daigo",
    ReservedUserName = "醍醐竜一",
    Start = new DateTime(2017, 6, 3, 18, 09, 0),
    End = new DateTime(2017, 6, 3, 10, 0, 0),
    AssignMembers = assignMembers
};

var callResult = manager.CreateDocumentWithPostTrigger(item).Result;

Console.WriteLine(callResult);

この文面上は分かりにくいですが、上記リスト17実行後には以下のメタデータドキュメントのCount値が+1のカウントアップを完了します。
ポスト・トリガー内で何らかの例外が発生した場合は、ドキュメント作成を含めてロールバックが行われます。

[
  {
    "id": "_metadata",
    "Room": "第1会議室",
    "Count": 5,
    "_rid": "k8tdAMsrbgA3AQAAAAAAAA==",
    "_self": "dbs/k8tdAA==/colls/k8tdAMsrbgA=/docs/k8tdAMsrbgA3AQAAAAAAAA==/",
    "_etag": "\"0100a2f4-0000-0000-0000-59328d810000\"",
    "_attachments": "attachments/",
    "_ts": 1496485249
  }
]

4.5 ユーザー定義関数(UDF)

最後に「ユーザー定義関数(UDF)」について説明します。
ユーザー定義関数(UDF)は「SQLクエリー内で使用することができるカスタム関数」です。 一般的なSQLにおいて max や sum などがありますが、その様な関数をカスタムに定義して利用することができます。

ここでは例として会議室予約時間を分数で計算するユーザー定義関数を作成します。つまり、RoomReservationsドキュメントのStartとEndから会議質予約時間を計算します。

(1)JavaScriptファイルを作成

リスト18が実装になります。
calcTimeUdf()ファンクションとして実装しています。引数として開始日時「start」及び終了日時「end」を受け取ります。
先のストアドプロシージャ・トリガーとの大きな違いとして、ユーザー定義関数(UDF)では Contextオブジェクト を使用することができません。現在の処理対象のドキュメントのような概念は存在せず、単純に引数で受け取った値に対して処理を行い、結果を返却します。

リスト18 calcTimeUdf.js
function calcTimeUdf(start, end)
{
  var startDate = Date.parse(start);
  var endDate = Date.parse(end);

  return (endDate - startDate) / 1000 / 60;
}

(2)ユーザー定義関数(UDF)の登録

DocumentDbManagerクラスに、ユーザー定義関数を登録するためのCreateUdf()メソッドを追加します(リスト19)。
使用するメソッド名が変わっただけで、先のストアドプロシージャ・トリガーとまったく同じ処理の流れになります。

リスト19 DocumentDbManager.cs
public async Task<bool> CreateUdf(string scriptFileName)
{
  string udfId = Path.GetFileNameWithoutExtension(scriptFileName);

  var udf = new UserDefinedFunction
  {
    Id = udfId,
    Body = File.ReadAllText(scriptFileName)
  };

  Uri collectionUrl =
    UriFactory.CreateDocumentCollectionUri(
      DocumentDbManager.DatabaseId,
      DocumentDbManager.CollectionId);

  // 存在したら削除
  var currentUdf = this.client.CreateUserDefinedFunctionQuery(collectionUrl)
    .Where( u => u.Id == udfId).AsEnumerable().FirstOrDefault();
  if (currentUdf != null)
  {
    var sp = await this.client.DeleteUserDefinedFunctionAsync(
      UriFactory.CreateUserDefinedFunctionUri(
        DocumentDbManager.DatabaseId, 
        DocumentDbManager.CollectionId, 
        currentUdf.Id));
  }

  // 作成
  udf = await client.CreateUserDefinedFunctionAsync(collectionUrl, udf);

  return true;
}

(3)ユーザー定義関数を使ったクエリーの実装

作成した「calcTimeUdf()」を使用したクエリーを事項するメソッドをDocumentDbManagerクラスに追加します(リスト20)。

リスト20 DocumentDbManager.cs
public List<RoomReservationInfo> FindUsingUdf(string room, int minutes)
{
  var query =
    this.client.CreateDocumentQuery<RoomReservationInfo>(
      UriFactory.CreateDocumentCollectionUri(
        DocumentDbManager.DatabaseId, 
        DocumentDbManager.CollectionId),
      new SqlQuerySpec(
        string.Format(
          "SELECT * FROM root WHERE root.Room = '{0}' " +
          "AND udf.calcTimeUdf(root.Start, root[\"End\"]) > {1}"
          , room, minutes)));

  var list = query.ToList();

  return list;
}

上記クエリーコードでは、以下のようなSQLを実行します。

SELECT * FROM root WHERE root.Room = ‘{0}’
AND udf.calcTimeUdf(root.Start, root[“End”]) > {1}

以下に2点ほど補足情報を説明します。

  • ユーザー定義関数が使用できる場所
    サンプルでは WHERE句 にユーザー定義関数を使用しました。
    それ以外に SELECT句 に使用することも可能です。

  • 予約語エスケープ(End)
    ユーザー定義関数固有のことではありませんが、実はドキュメントプロパティ「End」がCosmos DB SQL予約語になっています。
    予約語エスケープするために「root.End」ではなく「root[“End”]」という記述を行っています。

(4)実行

では(2)で実装したCreateUdf()メソッドを呼び出してユーザー定義関数を作成し、(3)で実装したFindUsingUdf()メソッドを呼び出してユーザー定義関数を利用したクエリーを実行します(リスト21)。

リスト21 Program.cs
var createResult = manager.CreateUdf(@"..\..\\calcTimeUdf.js").Result;
var roomReservationInfos = manager.FindUsingUdf("第1会議室", 90);

Console.WriteLine(string.Format("{0}件", roomReservationInfos.Count));
foreach (var info in roomReservationInfos)
{
  Console.WriteLine(
    string.Format("{0} / {1} / {2} ~ {3}", info.Room, info.Title, info.Start, info.End));
}

以下のような実行結果が得られます。

29件
第1会議室 / クラウド運用状況報告会(06/01回) / 2017/06/01 18:00:00 ~ 2017/06/01 20:00:00
第1会議室 / 障害対応会議(06/04回) / 2017/06/04 18:00:00 ~ 2017/06/04 20:00:00
第1会議室 / 障害対応会議(06/06回) / 2017/06/06 18:00:00 ~ 2017/06/06 20:00:00
第1会議室 / Azure社内勉強会~App Service編(06/07回) / 2017/06/07 17:00:00 ~ 2017/06/07 19:00:00
第1会議室 / Azure社内勉強会~Cosmos DB編(06/12回) / 2017/06/12 17:00:00 ~ 2017/06/12 19:00:00
第1会議室 / Azure社内勉強会~App Service編(06/17回) / 2017/06/17 17:00:00 ~ 2017/06/17 19:00:00
第1会議室 / 障害対応会議(06/18回) / 2017/06/18 18:00:00 ~ 2017/06/18 20:00:00
第1会議室 / 障害対応会議(06/22回) / 2017/06/22 17:00:00 ~ 2017/06/22 19:00:00
第1会議室 / ○○様オンサイトサポートについて / 2017/06/25 18:00:00 ~ 2017/06/25 20:00:00
第1会議室 / コードレビュー(06/30回) / 2017/06/30 18:00:00 ~ 2017/06/30 20:00:00
第1会議室 / クラウド運用状況報告会(07/01回) / 2017/07/01 18:00:00 ~ 2017/07/01 20:00:00
第1会議室 / 障害対応会議(07/02回) / 2017/07/02 17:00:00 ~ 2017/07/02 19:00:00
第1会議室 / コードレビュー(07/07回) / 2017/07/07 18:00:00 ~ 2017/07/07 20:00:00
第1会議室 / アーキテクチャ社内勉強会(07/09回) / 2017/07/09 17:00:00 ~ 2017/07/09 19:00:00
第1会議室 / 障害対応会議(07/22回) / 2017/07/22 18:00:00 ~ 2017/07/22 20:00:00
第1会議室 / ○○様オンサイトサポートについて / 2017/07/24 17:00:00 ~ 2017/07/24 19:00:00
第1会議室 / クラウド運用状況報告会(07/29回) / 2017/07/29 17:00:00 ~ 2017/07/29 19:00:00
第1会議室 / 営業会議(07/30回) / 2017/07/30 17:00:00 ~ 2017/07/30 19:00:00
第1会議室 / コードレビュー(08/02回) / 2017/08/02 17:00:00 ~ 2017/08/02 19:00:00
第1会議室 / 障害対応会議(08/05回) / 2017/08/05 17:00:00 ~ 2017/08/05 19:00:00
第1会議室 / ○○様オンサイトサポートについて / 2017/08/14 17:00:00 ~ 2017/08/14 19:00:00
第1会議室 / Azure社内勉強会~Cosmos DB編(08/15回) / 2017/08/15 17:00:00 ~ 2017/08/15 19:00:00
第1会議室 / クラウド運用状況報告会(08/18回) / 2017/08/18 17:00:00 ~ 2017/08/18 19:00:00
第1会議室 / Azure社内勉強会~App Service編(08/20回) / 2017/08/20 17:00:00 ~ 2017/08/20 19:00:00
第1会議室 / 障害対応会議(08/25回) / 2017/08/25 18:00:00 ~ 2017/08/25 20:00:00
第1会議室 / コードレビュー(08/26回) / 2017/08/26 17:00:00 ~ 2017/08/26 19:00:00
第1会議室 / ○○様オンサイトサポートについて / 2017/08/28 18:00:00 ~ 2017/08/28 20:00:00
第1会議室 / クラウド運用状況報告会(08/31回) / 2017/08/31 17:00:00 ~ 2017/08/31 19:00:00
第1会議室 / Azure社内勉強会~App Service編(09/02回) / 2017/09/02 17:00:00 ~ 2017/09/02 19:00:00
続行するには何かキーを押してください . . .

4.6 資料

本記事のサンプルは以下からダウンロード可能です。
github.com

次回は MongoDB編 に移りたいと思います。では~。

<< Azure Cosmos DB入門(3)へ | Azure Cosmos DB入門(5)へ >>

Azure Cosmos DB入門(3)

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

ryuichi111std.hatenablog.com

3 Cosmos DBプログラミング ~ DocumentDB編(前編)

ここからはCosmos DBに対して具体的な操作を行うプログラムについて説明していきます。
DocumentDB編・MongoDB編・Graph(Gremlin編)・Table編という形で、アクセスAPI毎に分けて説明します。

3.1 まず、はじめに

まずは、DocumentDBにおけるクエリー操作の基本概念について説明します。

3.1.1 DocumentDBとSQL

DocumentDB(ドキュメント データモデル)に対するクエリーの実行は「SQL構文」で行われます。
SQLは昔からのRDBで馴染みのある、おそらくデータベースに関連した開発の経験があるディベロッパーならだれもが知るデータ操作のための言語です。

3.1.2 REST API(DocumentDB API) と クラスライブラリ

まず、「REST API(DocumentDB API) と クラスライブラリ」という点について説明しておきます。
Cosmos DB(DocumentDB)では、「データベースの作成」や「コレクションの作成」、また「ストアドプロシージャの作成」のような ”低レベル(データベースの構造を操作する)機能” から、「ドキュメントの検索」のような ”アプリケーションレベルの機能” までを「REST API」で提供しています。
つまり、HTTP GET / POSTによってDocumentDB(Cosmos DB)に対する大半の操作が可能になっています。
また、DocumentDB(ドキュメント データモデル)に対するクエリーにはSQLが利用されると説明しましたが、RESTのPOSTパラメータとしてSQL文がリクエストされる仕組みを取ります。

そして、DocumentDB(Cosmos DB)を利用するプログラムを作成する場合、以下の2つの方法があります。

  • REST API(DocumentDB REST API) を呼び出すプログラムを書く
    HTTP通信の記述が可能なプログラム言語であれば、どんな環境でもDocumentDBを利用した実装を行う事が可能です。ただしこの場合、「URI文字列を構築し、POSTパラメータを作成し、必要なHTTP Headerを付加してリクエストを行い、応答のHTTPコードが200であることを確認し、応答されたJSONデータを利用(必要であればC# POCOにデシリアライズする処理を記述)」という様な煩雑な処理を行う必要があります。

  • クラスライブラリを使用する
    煩雑なHTTP通信処理をラップしたクラスライブラリを利用することになります。クラスライブラリのメソッドを呼び出すと、バックエンドで煩雑なRESTの通信を行ってくれます。

実際の開発では多くの場合クラスライブラリを利用した実装を行う事になると思います。しかし、実開発において一歩踏み込んだ実装を行っていく場合、クラスライブラリにラップされたREST通信を理解しておく必要があるケースもあります。そこでCosmos DBの学習や開発を進める上では、REST APIを知る事、またはFiddlerなどでどのような通信が行われているのかを調べる事も重要になります。

この後の説明では「言語はC#」「クラスライブラリ利用」を使用することをベースに話を進めます。

3.2 準備

3.2.1 Cosmos DB(DocumentDB)データベースアカウントの作成

Azureポータルを利用して「Cosmos DB(DocumentDB)」を作成します。
「CosmosDB入門(1)~(2)」でも、すでに触れているので、簡単に・・・

Azureポータルをブラウザで表示し「新規」を選択。検索テキストボックスに「Azure Cosmos DB」と入力。

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

「Azure Cosmos DB」を選択。

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

「作成」をクリック。

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

アカウント情報を入力して「作成」をクリックします。
ここでは以下の設定を行いました。
ID: cosmosdoc
APISQL(DocumentDB)
リソースグループ: cosmosdoc
場所: 西日本

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

以下のCosmos DBアカウントが作成されます。

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

3.2.2 Visual Studio 2017プロジェクトの作成

Visual Studio 2017を起動し、メニュー「ファイル → 新規作成 → プロジェクト」を選択します。
ここでは、プロジェクトテンプレートは「コンソールアプリケーション」、名前は「CosmosDocDBExample」としました。

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

次にCosmos DB(DocumentDB)にアクセスるつためにNuGetパッケージライブラリをプロジェクトに追加します。
ソリューションエクスプローラからプロジェクトをマウス右ボタンクリックし、表示されたメニューから「NuGetパッケージの管理を選択します。

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

表示されたNuGetパッケージ管理画面から、左上の「参照タブ」を選択し、検索テキストボックスに「Microsoft.Azure.DocumentDB」と入力します。
一覧に Microsoft.Azure.DocumentDB が表示されるので、選択して「インストール」ボタンをクリックします。

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

以上で、プロジェクトの下準備が完了しました。

3.2.2 データベースとコレクションの作成

ここでは、Cosmos DB(DocumentDB)操作を管理するクラスとして DocumentDbManagerクラス を用意することとします。
プロジェクトに DocumentDbManager.cs を追加します。

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

(1)接続情報等の定数定義

「接続情報」及びこれから「作成するデータベース名・コレクション名」をDocumentDbManagerクラスに定数として定義します(リスト 1)。

リスト 1
// DocumentDbManagerに定数定義を追加

private const string EndpointUrl = "【URI】";
private const string PrimaryKey = "【キー】";
private const string DatabaseId = "GroupwareDB";
private const string CollectionId = "RoomReservations";

// 上記は各値をコードに埋め込んでいますが、実運用コードでは構成から読み取る等の工夫が必要

URI】【キー】は各データベースアカウントごとに適切な値を設定します。
Azureポータルで「キー」タブを選択することで確認することができます。

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

データベースIDは「GroupwareDB」、コレクションIDは「RoomReservations」としました。これはサンプルとして、グループウェアにおける会議室予約のデータベースを想定します。


【コラム】プライマリキーとセカンダリキー

Azureポータル上で確認可能なデータベースアカウントに紐づいたキーは、プライマリキーとセカンダリキーの2つのキーがありました。
これらはどちらのキーを使用してもCosmos DBに接続することができます。
実運用時にはセキュリティ考慮の目的で、一定期間でキーを更新する方針が持たれることがあります。
稼働中のシステムを停止することなくキーの更新を行うためにセカンダリキーが用意されています。
例えば以下のような手順により、運用を止めることなくキーの更新が可能です。
(アクセスキーのローリング)  

  1. システムが利用するキーをセカンダリキーに切り替える
  2. プライマリキーを再作成(Azureポータル上で実施可能。数十秒程度で再作成完了)
  3. システムが利用するキーをプライマリキーに切り替える
  4. セカンダリキーも再作成

(2)基本名前空間のusing定義

DocumentDB接続・操作を行う上での基本的な名前空間をusing定義しておきます(リスト 2)。

リスト 2
...
// 一般的に利用する名前空間
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
 
// DocumentDB関連の名前空間
using Microsoft.Azure.Documents;
using Microsoft.Azure.Documents.Client;

(3)接続クライアントオブジェクトの定義

DocumentDBに接続する為の接続クライアント「Microsoft.Azure.Documents.Client.DocumentClient」をDocumentDbManagerクラスのフィールド変数「client」として定義します(リスト 3)。
また、clientはコンストラクタで初期化することとします。DocumentClientのコンストラクタには「データベースアカウントのエンドポイント」「キー」を引き渡します。

リスト 3
public class DocumentDbManager {
  private DocumentClient client = null;

  public DocumentDbManager()
  {
    this.client =
      new DocumentClient(
        new Uri(DocumentDbManager.EndpointUrl),
        DocumentDbManager.PrimaryKey);
  }
  ...
}

(4)データベース作成メソッドの追加

DocumentDbManagerクラスにデータベースを作成するメソッド「CreateDatabase()」を定義します(リスト 4)。

リスト 4
// データベースの作成
public async Task<Database> CreateDatabase()
{
  // データベースを作成
  Database database =
    await this.client.CreateDatabaseIfNotExistsAsync(
      new Database { Id = DocumentDbManager.DatabaseId });

  return database;
}

データベースの作成は「DocumentClient.CreateDatabaseIfNotExistsAsync()メソッド」で行うことができます。
引数は「Microsoft.Azure.Documents.Databaseオブジェクト」です。「Idプロパティ」が、作成するデータベースIDとなります。
メソッド名の通り「まだ存在しなかったらデータベースを作成(+データベース情報の返却)」を行います。データベースが既に存在したらデータベース情報の返却のみを行います。

以下が DocumentDbManager.CreateDatabase() 呼び出しコードです(リスト 5)。
(同期メソッドから非同期メソッドを呼び出しているので Wait() 呼び出しを付けています)

リスト 5
static void Main(string[] args)
{
  var manager = new DocumentDbManager();
  manager.CreateDatabase().Wait();
}

【コラム】CreateDatabaseIfNotExistsAsync()とREST API

DocumentDBへのアクセスには、REST APIとクラスライブラリが用意されていることは既に説明しました。
CreateDatabaseIfNotExistsAsync()メソッドはバックエンドでREST APIの呼び出しを行っています。
Fiddlerを使ってその様子を確認してみましょう。
以下がFiddlerのキャプチャです。2つのHTTPSリクエストが「https://cosmosdoc-japanwest.documents.azure.com」に送信されたのが分かります。

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

2つのHTTPS通信の詳細は以下の通りです。

① の HTTPSリクエスト

GET https://cosmosdoc-japanwest.documents.azure.com/dbs/GroupwareDB HTTP/1.1
x-ms-date: Sat, 27 May 2017 10:20:14 GMT
authorization: type%3dmaster%26ver%3d1.0%26sig%3dmdkOeMQIUPlqwrwmFsBv%2fWVWegqmjNfUvT%2f0D89IhPY%3d
Cache-Control: no-cache
x-ms-consistency-level: Session
User-Agent: documentdb-dotnet-sdk/1.14.1 Host/32-bit MicrosoftWindowsNT/6.2.9200.0
x-ms-version: 2017-02-22
Accept: application/json
Host: cosmosdoc-japanwest.documents.azure.com

↓↓↓ 上記に対するHTTPSレスポンス ↓↓↓

HTTP/1.1 404 Not Found
Transfer-Encoding: chunked
Content-Type: application/json
Content-Location: https://cosmosdoc-japanwest.documents.azure.com/dbs/GroupwareDB
Server: Microsoft-HTTPAPI/2.0
x-ms-last-state-change-utc: Sat, 27 May 2017 01:07:57.846 GMT
x-ms-schemaversion: 1.3
x-ms-xp-role: 1
x-ms-request-charge: 2
x-ms-serviceversion: version=1.14.23.1
x-ms-activity-id: 9f6459d5-12d8-441d-95ac-38b8b831562e
x-ms-session-token: 0:467
Strict-Transport-Security: max-age=31536000
x-ms-gatewayversion: version=1.14.23.1
Date: Sat, 27 May 2017 10:20:13 GMT

136
{"code":"NotFound","message":"Message: {\"Errors\":[\"Resource Not Found\"]}\r\nActivityId: 9f6459d5-12d8-441d-95ac-38b8b831562e, Request URI: /apps/bfb8961c-dcec-4c64-b341-400abf86ebdb/services/48b5f134-d51a-4e54-8546-231b6ae8eb02/partitions/d1768b54-6a08-48ad-916b-b27cfcd326f5/replicas/131397004676902200s"}
0
② の HTTPSリクエスト

POST https://cosmosdoc-japanwest.documents.azure.com/dbs HTTP/1.1
x-ms-date: Sat, 27 May 2017 10:20:14 GMT
authorization: type%3dmaster%26ver%3d1.0%26sig%3dOfaltgioZC7s9CkkUg0rb%2b9WESuLoYUaH4cuMuIdpvQ%3d
Cache-Control: no-cache
x-ms-consistency-level: Session
User-Agent: documentdb-dotnet-sdk/1.14.1 Host/32-bit MicrosoftWindowsNT/6.2.9200.0
x-ms-version: 2017-02-22
Accept: application/json
Host: cosmosdoc-japanwest.documents.azure.com
Content-Length: 20
Expect: 100-continue

{"id":"GroupwareDB"}

↓↓↓ 上記に対するHTTPSレスポンス ↓↓↓

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: Sat, 27 May 2017 03:39:16.214 GMT
etag: "00000701-0000-0000-0000-592952de0000"
x-ms-resource-quota: databases=100;
x-ms-resource-usage: databases=1;
x-ms-schemaversion: 1.3
x-ms-quorum-acked-lsn: 467
x-ms-current-write-quorum: 3
x-ms-current-replica-set-size: 4
x-ms-xp-role: 1
x-ms-request-charge: 4.95
x-ms-serviceversion: version=1.14.23.1
x-ms-activity-id: a5ed7f76-deb4-44a5-b7e5-d229420e89d2
x-ms-session-token: 0:468
x-ms-gatewayversion: version=1.14.23.1
Date: Sat, 27 May 2017 10:20:14 GMT

AA
{"id":"GroupwareDB","_rid":"GA8GAA==","_self":"dbs\/GA8GAA==\/","_etag":"\"00000701-0000-0000-0000-592952de0000\"","_colls":"colls\/","_users":"users\/","_ts":1495880413}
0

1つめのHTTPSリクエストは、「https://cosmosdoc-japanwest.documents.azure.com/dbs/GroupwareDB」(/dbs/GroupwareDはデータベースを表すリソースURI)に対してGETが行われています。
つまり以下の意味を持ちます。

  • 西日本リージョンの cosmosdoc データベースアカウント配下の GroupwareDB というIDのデータベース情報を取得する

結果は404 NotFoundでした。つまり対象のデータベースが存在しないという結果が応答されました。
CreateDatabaseIfNotExistsAsync()メソッドは、対象のデータベースが存在しなかったので作成処理を行います。それが2つ目のHTTPリクエストです。
https://cosmosdoc-japanwest.documents.azure.com/dbs」に対して、パラメータ「{“id”:“GroupwareDB”}」でPOSTが行われています。
つまり以下の意味を持ちます。

  • 西日本リージョンの cosmosdoc データベースアカウント配下に GroupwareDB というIDのデータベースを作成する

結果は201 Created。BODY内に作成したデータベースに関する情報をJSON形式で返却してくれました。


(5)コレクション作成メソッドの追加

DocumentDbManagerクラスにコレクションを作成するメソッド「CreateCollection()」を定義します(リスト 6)。

リスト 6
// コレクションの作成
public async Task<DocumentCollection> CreateCollection()
{
  // パーティションキー指定あり
  DocumentCollection collection = new DocumentCollection();
  collection.Id = DocumentDbManager.CollectionId;
  collection.PartitionKey.Paths.Add("/Room");

  // スループットは 2500RU
  RequestOptions options = new RequestOptions();
  options.OfferThroughput = 2500;

  // コレクションを作成
  collection =
    await this.client.CreateDocumentCollectionIfNotExistsAsync(
      UriFactory.CreateDatabaseUri(DocumentDbManager.DatabaseId),
      collection, options);

  return collection;
}
  • DocumentCollection
    DocumentCollectionオブジェクトにより、作成するコレクションの情報を設定します。
    DocumentCollection.Idは作成するコレクションのIDであり、ここでは事前に定数定義を行ったDocumentDbManager.CollectionIdを設定します。
    また、パーティションキーを設定する事とします(DocumentCollection.PartitionKey.Paths.Add())。ドキュメントスキーマは後で定義しますが、「/Room」をパーティションキーとします。

  • RequestOptions
    RequestOptionsオブジェクトのOfferThroughputプロパティ設定によりスループット(つまり、予約するRU/s)を設定します。パーティションキーを設定したので設定可能な最低値である2500を設定します(パーティションキーを指定しない場合、最低値の400RUを設定することができます)。

  • CreateDocumentCollectionIfNotExistsAsync()
    用意した DocumentCollection / RequestOptions を引数としてコレクションを作成します(CreateDocumentCollectionIfNotExistsAsync())。このメソッドは、データベースの作成と同様に「存在しなかったらコレクション作成+コレクション情報返却、存在したらコレクション情報返却」を行う動作になります。
    順序が逆転しますが、CreateDocumentCollectionIfNotExistsAsync()メソッドの第1引数に注目します。
    第1引数は「どのデータベースに」コレクションを作成するか?の設定になります。
    UriFactory.CreateDatabaseUri(【データベースID】)の結果として、データベースリソースを表すURIが返却されます。
    具体的には、UriFactory.CreateDatabaseUri(“GroupwareDB”)の返却値は「dbs/GroupwareDB」となります。
    次に、CreateDocumentCollectionIfNotExistsAsync()メソッドの保有者であるDocumentClientオブジェクトについて振り返りましょう。
    DocumentClientは、コンストラクタでサービスエンドポイントを受け取っています。サービスエンドポイントは例えば「https://cosmosdoc.documents.azure.com:443/」のようなものになります。これにデータベースURIを結合すると以下になります。
    https://cosmosdoc.documents.azure.com:443/dbs/GroupwareDB
    データベースアカウント「cosmosdoc」、データベースを表す「dbs」、データベースID「GroupwareDB」・・つまりデータベースを表すユニークな「リソースURI」となります。この配下に「RoomReservations」コレクションを作成する、という指示につながることが理解できます。
    そして、コレクション作成時のFiddlerの画面は以下の通りです。

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

HTTPSリクエストURLを見ると以下となっています。

https://cosmosdoc.documents.azure.com:443/dbs/GroupwareDB/colls/RoomReservations

データベースアカウント「cosmosdoc」、データベースを表す「dbs」、データベースID「GroupwareDB」、コレクションを表す「colls」、コレクションID「ReservationRoom」・・つまりコレクションを表すユニークな「リソースURI」となります。


【コラム】Resource URI と UriFactoryクラス

DocumentDBにおいてデータベースやコレクション、ドキュメントといったリソースはすべてURIで表される仕組みをとります。

URI Path 説明
/dbs データベースアカウント配下のデータベース
/dbs/{databaseId} データベースアカウント配下のデータベースID=databaseIdのデータベース
/dbs{databaseId}/colls databaseIdデータベース配下のコレクション
/dbs{databaseId}/colls/{collectionId} databaseIdデータベース配下のcollectionIdコレクション
/dbs{databaseId}/colls/{collectionId}/docs collectionIdコレクション配下のドキュメント
/dbs{databaseId}/colls/{collectionId}/docs/{docId} collectionIdコレクション配下のdocIdドキュメント
/dbs{databaseId}/colls/{collectionId}/docs/{docId}/attachments/{attachmentsId} docIdドキュメント配下のattachmentsId1添付
/dbs{databaseId}/colls/{collectionId}/sprocs/{procId} collectionIdコレクション配下のprocIdストアドプロシージャ
/dbs{databaseId}/colls/{collectionId}/triggers/{triggerId} collectionIdコレクション配下のtriggerIdトリガー
/dbs{databaseId}/colls/{collectionId}/udfs/{udfId} collectionIdコレクション配下のudfIdユーザー定義関数
/dbs/{databaseId}/users databaseIdデータベース配下のユーザー
/dbs/{databaseId}/users/{userId} databaseIdデータベース配下のユーザーID=userIdのユーザー
/dbs/{databaseId}/users/{userId}/permissions userIdユーザーのパーミッション
/dbs/{databaseId}/users/{userId}/permissions/{permissionId} userIdユーザーのpermissionIdパーミッション

各リソースのURIを作成するヘルパーメソッドが「Microsoft.Azure.Documents.Client.UriFactoryクラス」で用意されています。

  • UriFactory.CreateDatabaseUri()
  • UriFactory.CreateCollectionUri()
  • UriFactory.CreateDocumentUri()
  • UriFactory.CreateStoredProcedureUri()
  • UriFactory.CreateTriggerUri()
  • UriFactory.CreateUserDefinedFunctionUri()
  • UriFactory.CreateUserUri()
  • UriFactory.CreatePermissionUri()
  • UriFactory.CreateAttachmentUri()
  • UriFactory.CreateConflictUri()
  • UriFactory.CreateDocumentCollectionUri()

これらのヘルパーメソッドはこの後も頻繁に使用します。


3.2.3 ドキュメントモデルの作成

RoomReservationsコレクションに保存するドキュメントを表すC#モデルクラスを用意します。
DocumentDB上ではドキュメントはJSON形式で保持されますが、C#コード上ではモデルクラスにマッピングするとコーディングを行う上で便利です。

以下のように RoomReservationInfo.cs をプロジェクトに追加します。

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

RoomReservationInfoクラスの実装は以下の通り(リスト 7)。

リスト 7
using System;
using System.Collections.Generic;
using Newtonsoft.Json;

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

    /// <summary>
    /// 会議室名を取得または設定します。
    /// </summary>
    public string Room { get; set; }

    /// <summary>
    /// 会議名を取得または設定します。
    /// </summary>
    public string Title { get; set; }

    /// <summary>
    /// 予約者IDを取得または設定します。
    /// </summary>
    public string ReservedUserId { get; set; }

    /// <summary>
    /// 予約者名を取得または設定します。
    /// </summary>
    public string ReservedUserName { get; set; }

    /// <summary>
    /// 開始日時を取得または設定します。
    /// </summary>
    public DateTime Start { get; set; }

    /// <summary>
    /// 終了日時を取得または設定します。
    /// </summary>
    public DateTime End { get; set; }

    /// <summary>
    /// 参加メンバーを取得または設定します。
    /// </summary>
    public List<AssignMember> AssignMembers { get; set; }
  }

  public class AssignMember
  {
    public string UserId { get; set; }

    public string UserName { get; set; }
  }
}

RoomReservationInfoクラスは、会議予約を表すものとし、AssignMembersが会議への参加者を表すものとします。
IDプロパティについてJSONでは id となるように定義しています(JsonProperty属性)。
DocumnetDBのドキュメントにおいて「id」というキーワードは、ドキュメントを一意に識別するための特別なキーワードです。そのため、C#クラス定義上は Id となっているプロパティを、DocumentDBに保存する際にJSON形式では id となるようにしています。

ドキュメントの一意識別子

ドキュメントは、コレクション内で一意となる識別子を持つ必要があります。
パーティションキーを「指定している場合」と「指定していない場合」でルールが異なります。

  • パーティションキーが指定されている場合
    コレクション内のドキュメントは「パーティションキー項目とid項目」によって一意である必要があります。

  • パーティションキーが指定されていない場合
    コレクション内のドキュメントは「id項目」によって一意である必要があります。

3.3 ドキュメントの作成・更新

ドキュメントを作成および更新する処理を追加します。
DocumentDbManagerクラスにRoomReservationInfoドキュメントを作成(INSERT)するメソッド「CreateDocument()」を定義します(リスト 8)。

リスト 8
public async Task<Document> CreateDocument(
    RoomReservationInfo roomReservationInfo)
{
  var document =
    await this.client.CreateDocumentAsync(
      UriFactory.CreateDocumentCollectionUri(
        DocumentDbManager.DatabaseId,
        DocumentDbManager.CollectionId),
      roomReservationInfo);

  return document;
}

ドキュメントの作成は「DocumentClient.CreateDocumentAsunc()」で行うことが出来ます。

DocumentDbManager.CreateDocument()を呼び出すコードが以下になります(リスト 9)。
ひとまずここではソースとなるRoomReservationInfoオブジェクトを固定でべた書きしています。

リスト 9
// ドキュメントソースオブジェクトを用意
var assignMembers = new List<AssignMember>();
assignMembers.Add(new AssignMember() 
    { UserId = "tanaka", UserName = "田中和夫" });
assignMembers.Add(new AssignMember() 
    { UserId = "sakamoto", UserName = "坂本寛子" });
RoomReservationInfo item = new RoomReservationInfo()
{
  Id = "00001",
  Room = "第1会議室",
  Title = "Cosmos DB移行についての打ち合わせ",
  ReservedUserId = "daigo",
  ReservedUserName = "醍醐竜一",
  Start = new DateTime(2017, 5, 30, 10, 0, 0),
  End = new DateTime(2017, 5, 30, 11, 0, 0),
  AssignMembers = assignMembers
};

// ドキュメント作成メソッド呼び出し
var manager = new DocumentDbManager();
manager.CreateDocument(item).Wait();

上記コードで作成されたドキュメントを、Azureポータルの「クエリ エクスプローラ」で確認した画面が以下です。

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

3.3.1 idの明示的な指定と暗黙的な自動採番

上記 リスト 9 のドキュメント作成コードでは id (RoomReservationInfo.Id) の値を明示的に指定しました。
識別子である id 値は未指定としてドキュメントを作成することも可能です。
未指定の場合、自動的に「GUID文字列」が id 値として設定されます。
idを指定しなかった場合に作成されるドキュメントの例は以下の通りです(リスト 10)。

リスト 10
[
  {
    "id": "00838cf1-1c6e-49cd-aee5-b7d2f1e96677",
    "Room": "第1会議室",
    "Title": "Cosmos DB移行についての打ち合わせ",
    "ReservedUserId": "daigo",
    "ReservedUserName": "醍醐竜一",
    "Start": "2017-05-30T10:00:00",
    "End": "2017-05-30T11:00:00",
    "AssignMembers": [
      {
        "UserId": "tanaka",
        "UserName": "田中和夫"
      },
      {
        "UserId": "sakamoto",
        "UserName": "坂本寛子"
      }
    ],
    "_rid": "UyJwAK9QBwABAAAAAAAACA==",
    "_self": "dbs/UyJwAA==/colls/UyJwAK9QBwA=/docs/UyJwAK9QBwABAAAAAAAACA==/",
    "_etag": "\"00002305-0000-0000-0000-5929769d0000\"",
    "_attachments": "attachments/",
    "_ts": 1495889564
  }
]

3.3.2 CreateDocumentAsync()とUpsertDocumentAsync()

上記 リスト 8 の実装では、ドキュメントを作成するメソッドとして「CreateDocumentAsync()」を使用しました。
これ以外に「UpsertDocumentAsync()」というメソッドがあります。メソッド名から想像できる通り、ドキュメントが存在しなかったら INSERT、ドキュメントが存在したら UPDATE を行います。

ドキュメントの存在有無とは・・・
* パーティションキー指定ありの場合:
コレクション内に「id と パーティションキー」が同一のドキュメントがあるか?
* パーティションキー指定なしの場合:
コレクション内に「id」が同一のドキュメントがあるか?

です。

UpsertDocumentAsync()を利用するように修正したCreateDocument()の実装は以下の通りです(リスト 11)。
メソッド名も、実装の実態に合わせ「SaveDocument()」としました。

リスト 11
//public async Task<Document> CreateDocument(RoomReservationInfo roomReservationInfo)
public async Task<Document> SaveDocument(RoomReservationInfo roomReservationInfo)
{
    var document =
        await this.client.UpsertDocumentAsync(
        UriFactory.CreateDocumentCollectionUri(
            DocumentDbManager.DatabaseId, 
            DocumentDbManager.CollectionId),
        roomReservationInfo);

    return document;
}

例として以下のコードを実行します。

リスト 12
var manager = new DocumentDbManager();

var assignMembers = new List<AssignMember>();
assignMembers.Add(new AssignMember() { UserId = "tanaka", UserName = "田中和夫" });
assignMembers.Add(new AssignMember() { UserId = "sakamoto", UserName = "坂本寛子" });
RoomReservationInfo item = new RoomReservationInfo()
{
    Id = "00001",
    Room = "第1会議室",
    Title = "Cosmos DB移行についての打ち合わせ",
    ReservedUserId = "daigo",
    ReservedUserName = "醍醐竜一",
    Start = new DateTime(2017, 5, 30, 10, 0, 0),
    End = new DateTime(2017, 5, 30, 11, 0, 0),
    AssignMembers = assignMembers
};
manager.SaveDocument(item).Wait();

//
item.Room = "第2会議室";
manager.SaveDocument(item).Wait();

//
item.Room = "第1会議室";
item.Title = "タイトルを変更!";
manager.SaveDocument(item).Wait();

上記 リスト 12 実行後のコレクション内ドキュメントは、以下の2件になります(リスト 13)。

リスト 13
[
  {
    "id": "00001",
    "Room": "第1会議室",
    "Title": "タイトルを変更!",
    "ReservedUserId": "daigo",
    "ReservedUserName": "醍醐竜一",
    "Start": "2017-05-30T10:00:00",
    "End": "2017-05-30T11:00:00",
    "AssignMembers": [
      {
        "UserId": "tanaka",
        "UserName": "田中和夫"
      },
      {
        "UserId": "sakamoto",
        "UserName": "坂本寛子"
      }
    ],
    "_rid": "bel5AIFBHAABAAAAAAAACA==",
    "_self": "dbs/bel5AA==/colls/bel5AIFBHAA=/docs/bel5AIFBHAABAAAAAAAACA==/",
    "_etag": "\"0000106c-0000-0000-0000-5929795c0000\"",
    "_attachments": "attachments/",
    "_ts": 1495890267
  },
  {
    "id": "00001",
    "Room": "第2会議室",
    "Title": "Cosmos DB移行についての打ち合わせ",
    "ReservedUserId": "daigo",
    "ReservedUserName": "醍醐竜一",
    "Start": "2017-05-30T10:00:00",
    "End": "2017-05-30T11:00:00",
    "AssignMembers": [
      {
        "UserId": "tanaka",
        "UserName": "田中和夫"
      },
      {
        "UserId": "sakamoto",
        "UserName": "坂本寛子"
      }
    ],
    "_rid": "bel5AIFBHAABAAAAAAAADA==",
    "_self": "dbs/bel5AA==/colls/bel5AIFBHAA=/docs/bel5AIFBHAABAAAAAAAADA==/",
    "_etag": "\"00008c20-0000-0000-0000-5929795c0000\"",
    "_attachments": "attachments/",
    "_ts": 1495890268
  }
]

↓↓↓2017.06.03追記↓↓↓

楽観的同時実行制御(optimistic concurrency control)による更新について

楽観的同時実行制御によるデータデータ更新を行う場合について補足します。
詳細にはまだ触れていませんが、ドキュメントには etag というシステムにより自動採番される値が割り当てられています。etagは更新を行う毎に自動的に更新されます。etagを使用することで、楽観的同時実行制御を行うことができます。
実装イメージは以下になります。 RoomReservationInfoクラスに
etag を追加します。ドキュメントの取得時に、その時点での etag 値を保持するためです。
DocumentDbManagerクラスに ReplaceDocument() メソッドを追加します。client.ReplaceDocumentAsync()メソッドを呼び出しますが、この時に「RequestOptions.AccessCondition」により
etagの値を指定します。AccessConditionType.IfMatchは楽観的同時実行制御を実施する事を意味します。

// RoomReservationInfoクラス定義
public class RoomReservationInfo
{
  .. 省略
  
  // etagを追加
  public string _etag { get; set; }
}


// DocumentDbManagerクラスメソッド定義
public async Task<Document> ReplaceDocument(
      RoomReservationInfo roomReservationInfo)
{
  var doc = await this.client.ReplaceDocumentAsync(
    UriFactory.CreateDocumentUri(
      DocumentDbManager.DatabaseId,
      DocumentDbManager.CollectionId,
      roomReservationInfo.Id),
    roomReservationInfo,
    new RequestOptions()
    {
      AccessCondition = new AccessCondition()
      {
        Condition = roomReservationInfo._etag,
        Type = AccessConditionType.IfMatch
      }
    });

  return doc;
}

// 呼び出しイメージ
// ①ドキュメント取得
var reservation = manager.FindById("第1会議室", "0000000001");
// ②データ修正
reservation.Title = "変更!" + DateTime.Now.ToString("yyyyMMddHms");
// ③DB更新
var doc = manager.ReplaceDocument(reservation).Result;

// ①と③の間に別プロセスによりデータ変更が行われていたらetagによる同時実行制御により更新が失敗する

↑↑↑2017.06.03追記↑↑↑

3.3.3 ドキュメントの一括投入 ~ DocumentDB Data Migration Tool

以降の検索の説明等ではもっと大量のドキュメントが登録されている状態が好ましいです。
その為に、データ量を増やしておきたいと思います。
せっかく用意した DocumentDbManager.CreateDocument() メソッドを繰り返し呼び出してもよいのですが、一括でドキュメントを投入可能 な「DocumentDB Data Migration Tool」を使うことにします。

DocumentDB Data Migration Toolは「DocumentDB→DocumentDB」や「jsonファイル→DocumentDB」「DocumentDB→jsonファイル」「MongoDB→DocumentDB」「DynamoDB→DocumentDB」等々のデータマイグレーションを行ってくれるツールです。
ここでは、事前に用意した300件のRoomReservationInfoを定義したJSONファイル(exampledata.json)から、DocumentDBへのデータ投入を行います。

その前に、作成済みの2件のドキュメントを削除しておきましょう。
削除の方法はいくつもありますが、Azureポータルで「ドキュメント エクスプローラ」を利用すると、ドキュメントを1件づつですが削除することができます(ここでは2つなのでOK)。

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

「DocumentDB Data Migration Tool」は以下からダウンロードすることができます。

Download Azure DocumentDB Data Migration Tool from Official Microsoft Download Center

(1) DocumentDB Data Migration Toolを起動

ダウンロードしたzipファイルを解凍し「dtui.exe」を実行します。
「Next」をクリックします。

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

(2)データソースを選択

データソースの形式は「JSON file(s)」を選択し、「Add Files」ボタンをクリックして対象のJSONファイルを選択します。
「Next」ボタンをクリックします。

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

(3)エクスポート先情報を入力

エクスポート先情報を入力します。
「Verify」ボタンで値の妥当性を確認してから、「Next」ボタンをクリックします。

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

  • エクスポート先:
    DocumnetDB - Sequential import(partitioned collection)

  • Connection String:
    Azureポータルの「キー タブ」のプライマリ接続文字列をコピーし、「Database=【データベース名】」を付与した文字列を設定します。

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

例)AccountEndpoint=https://cosmosdoc.documents.azure.com:443/;AccountKey=[キー];Database=GroupwareDB

(4)ログ出力先等の設定

エラーログの出力先や更新のインターバルを指定して「Next」ボタンをクリックします。

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

(5)インポート実行

設定内容を確認して、間違えがなければ「Import」ボタンをクリックします。

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

(6)インポート完了

インポートが完了します。

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

Azureポータル上からもデータがインポートされたことを確認することができます。

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

3.4 ドキュメントの検索

ここまでで、「GroupwareDBデータベース の RoomReservationsコレクション に RoomReservationInfoドキュメント が300件」存在する状態になりました。
いくつかのパターンで検索を行ってみたいと思います。
前述でDocumentDBへのクエリーはSQL言語で行われると説明しました。
クラスライブラリ(Microsoft.Azure.DocumentDB)を使用した場合、「SQLを直接記述する方法」と「LINQ構文を使用する方法」があります。

3.4.1 LINQによる検索

LINQを使用した場合、ライブラリ内部及びCosmos DB(DocumentDB)では、以下の図ような振る舞いが行われます。つまり、最終的にはSQL構文で実行されることになります(Entity Frameworkを使った際のSQL Server接続と同様です)。

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

では、具体的な実装を。

(1)シンプルな条件検索

1つ目のクエリーサンプルは、抽出条件として「Room」を指定するサンプルです。
DocumentDbManagerに以下のメソッドを追加します(リスト 14)。

リスト 14
public List<RoomReservationInfo> FindByRoom(string room)
{
  var query =
    this.client.CreateDocumentQuery<RoomReservationInfo>(
      UriFactory.CreateDocumentCollectionUri(
        DocumentDbManager.DatabaseId, DocumentDbManager.CollectionId)).
      Where(r => r.Room == room);
  var result = query.ToList();

  return result;
}

呼び出し側の実装は、以下の通りです(リスト 15)。

リスト 15
var manager = new DocumentDbManager();

List<RoomReservationInfo> roomReservationIngos = 
  manager.FindByRoom("スタンディングテーブル");

// 検索結果出力
Console.WriteLine(string.Format("{0}件", roomReservationIngos.Count));
foreach (var info in roomReservationIngos)
{
  Console.WriteLine(
    string.Format("{0} / {1} / {2} ~ {3}", info.Room, info.Title, info.Start, info.End));
}

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

97件
スタンディングテーブル / アーキテクチャ社内勉強会(06/02回) / 2017/06/02 13:00:00 ~ 2017/06/02 14:30:00
スタンディングテーブル / 営業会議(06/02回) / 2017/06/02 17:00:00 ~ 2017/06/02 19:00:00
...省略
スタンディングテーブル / ○○様オンサイトサポートについて / 2017/09/07 9:00:00 ~ 2017/09/07 10:00:00
スタンディングテーブル / 進捗会議(08/10回) / 2017/08/10 10:00:00 ~ 2017/08/10 11:00:00

続行するには何かキーを押してください . . .

加えて、検索処理のHTTPS通信をFiddlerによって監視した結果を以下に示します。

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

「RoomReservationsコレクション情報の取得」と「ドキュメントの取得」の2つのHTTPSリクエストが行われています。
2つめのHTTPSリクエストは以下の通りです。

POST https://cosmosdoc-japanwest.documents.azure.com/dbs/GroupwareDB/colls/RoomReservations/docs HTTP/1.1
x-ms-continuation: 
x-ms-documentdb-isquery: True
x-ms-documentdb-query-enablecrosspartition: False
x-ms-documentdb-query-iscontinuationexpected: False
x-ms-documentdb-populatequerymetrics: False
x-ms-date: Sat, 27 May 2017 16:43:35 GMT
authorization: type%3dmaster%26ver%3d1.0%26sig%3dcS4wmQgHbflDa7VXTOrqKrlUowyeQNK6h9D9lJj8Yug%3d
Cache-Control: no-cache
x-ms-consistency-level: Session
User-Agent: documentdb-dotnet-sdk/1.14.1 Host/32-bit MicrosoftWindowsNT/6.2.9200.0
x-ms-version: 2017-02-22
Accept: application/json
Content-Type: application/query+json
Host: cosmosdoc-japanwest.documents.azure.com
Content-Length: 98
Expect: 100-continue

{"query":"SELECT * FROM root WHERE (root[\"Room\"] = \"スタンディングテ\\u30fcブル\") "}

BODYのJSONデータとして「SQL」が設定されています。
FROM句の「root」は、コレクションに属するデータを表す予約語です。POSTリクエストのURIhttps://cosmosdoc-japanwest.documents.azure.com/dbs/GroupwareDB/colls/RoomReservations/docs」によって、クエリーの対象コレクションが明示されているため、rootという表現が可能になります。
WHERE句もLINQメソッド構文で指定した内容がSQLに展開されていることが分かります。

(2)予約者による条件検索(クロスパーティション検索)

次に予約者による検索を実装します。
DocumentDbManagerに以下のメソッドを追加します(リスト 16)。

リスト 16
public List<RoomReservationInfo> FindByReservedUserId(string userId)
{
  FeedOptions feedOptions = new FeedOptions()
  {
    EnableCrossPartitionQuery = true
  };

  var query =
    this.client.CreateDocumentQuery<RoomReservationInfo>(
      UriFactory.CreateDocumentCollectionUri(DocumentDbManager.DatabaseId,
       DocumentDbManager.CollectionId),
      feedOptions)
      .Where(r => r.ReservedUserId == userId);
  var result = query.ToList();

  return result;
}

FeedOptionsオブジェクトを作成し、CreateDocumentQuery()メソッドの第2引数に設定しています。また、FeedOptions.EnableCrossPartitionQueryプロパティを true に設定しています。
これは非常に重要な設定です。
本サンプルの RoomReservationsコレクション には、パーティションキーとして「/Room」を設定しました。検索条件に「Room」が含まれていない場合、「クロスパーティション検索」というものになります。CreateDocumentQuery()呼び出し時に、明示的に FeedOptions.EnableCrossPartitionQueryプロパティ の値を true に設定していないと例外が発生してしまいます。

「クロスパーティション検索」は、検索条件に パーティションキーの値 を指定したものと比較して負荷の高い(消費RUの高い)クエリーになります。
この点に関しては、設定するパーティションキーの設計段階からの検討が必要になります。

呼び出し側の実装は、以下の通りです。

リスト 17
List<RoomReservationInfo> roomReservationInfos = manager.FindByReservedUserId("daigo");
Console.WriteLine(string.Format("{0}件", roomReservationInfos.Count));
foreach (var info in roomReservationInfos)
{
  Console.WriteLine(
    string.Format("{0} / {1} / {2} ~ {3}", info.Room, info.Title, info.Start, info.End));
}

【コラム】クロスパーティション検索時のHTTPSリクエス

リスト 16 のクロスパーティション検索が実行された場合のHTTPSリクエストの様子をFiddlerで取得したのが以下です。

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

「/dbs/GroupwareDB/colles/RoomReservations/docs」へのリクエストが10回行われていることが分かります。

次に、以下のロジックを実行してみます。
コレクションのパーティションキーレンジ情報の取得を行う処理になります。

// パーティションキーレンジ情報の取得
var pkRanges =
  await this.client.ReadPartitionKeyRangeFeedAsync(
    UriFactory.CreateDocumentCollectionUri(
      DocumentDbManager.DatabaseId,
      DocumentDbManager.CollectionId
    )
  );
var list = pkRanges.ToList();

上記が実行された際のFiddlerによるHTTPS通信の様子が以下です。

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

上記から分かることは、RoomReservationsコレクションは「10」のパーティションに分割されドキュメントが保持されている、ということです。
”同一パーティションキー値を持つドキュメント”は”同一のパーティション”に保持されます。逆に、パーティションキー値が異なるドキュメントは、異なるパーティションに保持される可能性があります。
パーティションキーを指定したクエリーでは、単一のパーティションに対するクエリーで素早くデータを取得できますが、パーティションキー指定のないクエリー、つまり複数のパーティションにまたがる可能性のあるクエリーでは「各パーティションへの問い合わせ」が必要になります。
これにより、クロスパーティション検索は多くの RU/s を消費することになります。


(3)結果件数とMaxItemCount

検索時のオプション設定「FeedOptions」には「MaxItemCountプロパティ」というものがあります。
DocumentDBへの1度の問い合わせで取得するアイテム(ドキュメント)の最大件数の指定になります。

DocumentDbManagerに以下のメソッドを追加します(リスト 18)。

リスト 18
public List<RoomReservationInfo> FindByRoom2(string room)
{
  FeedOptions feedOptions = new FeedOptions()
  {
    MaxItemCount = 5
  };

  var query =
    this.client.CreateDocumentQuery<RoomReservationInfo>(
      UriFactory.CreateDocumentCollectionUri(
        DocumentDbManager.DatabaseId, 
        DocumentDbManager.CollectionId),
      feedOptions).
      Where(r => r.Room == room);
  var result = query.ToList();

  return result;
}

呼び出し側の実装は、以下の通りです(リスト 19)。

リスト 19
List<RoomReservationInfo> roomReservationInfos = manager.FindByRoom2("第1会議室");

上記コードによる検索で発生したHTTPSリクエストを監視したFiddler画面は以下の通りです。

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

5件づつの取得処理を繰り返し呼び出している様子を確認することができます。
MaxItemCountを超える検索結果があった場合、クラスライブラリが自動的に裏側で必要な数のHTTPSリクエストを発行します。

(4)スカラー値検索

スカラー値検索の例として、検索条件に合致する件数を取得する処理を実装します。
DocumentDbManagerに以下のメソッドを追加します(リスト 20)。
クエリーオブジェクトに対して「Count()」LINQメソッドを呼び出すことで、件数の取得を行うことができます。

リスト 20
public int CountByRoom(string room)
{
  var query =
    this.client.CreateDocumentQuery<RoomReservationInfo>(
      UriFactory.CreateDocumentCollectionUri(
        DocumentDbManager.DatabaseId, 
        DocumentDbManager.CollectionId)).
      Where(r => r.Room == room);
  var result = query.Count();

  return result;
}

(5)子要素による検索

次に、RoomReservationInfo.AssignMember要素の値による検索を行いたいと思います。
DocumentDbManagerに以下のメソッドを追加します(リスト 21)。

リスト 21
public List<RoomReservationInfo> FindByAssignMember(string room, AssignMember member)
{
  var query =
    this.client.CreateDocumentQuery<RoomReservationInfo>(
      UriFactory.CreateDocumentCollectionUri(
        DocumentDbManager.DatabaseId, DocumentDbManager.CollectionId))
      .Where(r => r.Room == room && r.AssignMembers.Contains(member));
  var result = query.ToList();

  return result;
}

呼び出し側の実装は、以下の通りです(リスト 22)。

リスト 22
List<RoomReservationInfo> roomReservationInfos = 
  manager.FindByAssignMember(
    "第1会議室", 
    new AssignMember() { UserId = "daigo", UserName = "醍醐竜一" });

Console.WriteLine(string.Format("{0}件", roomReservationInfos.Count));
foreach (var info in roomReservationInfos)
{
    Console.WriteLine(
        string.Format("{0} / {1} / {2} ~ {3}", info.Room, info.Title, info.Start, info.End));
}

3.4.2 SQL指定による検索

LINQ構文ではなくSQLを直接指定した検索を行うことも可能です。
このあたりの感覚もEntity Frameworkと同じですね。
DocumentDbManagerに以下のメソッドを追加します(リスト 23)。

リスト 23
public RoomReservationInfo FindById(string room, string id)
{
  var query =
    this.client.CreateDocumentQuery<RoomReservationInfo>(
      UriFactory.CreateDocumentCollectionUri(
        DocumentDbManager.DatabaseId, DocumentDbManager.CollectionId),
      new SqlQuerySpec(
        string.Format(
        "SELECT * FROM root WHERE root.Room = '{0}' AND root.id = '{1}'",
        room, id)));
  var list = query.ToList();

  return list.Count > 0 ? list[0] : null;
}

DocumentClient.CreateDocumentQuery()メソッドの引数として「SqlQuerySpecオブジェクト」を引き渡します。SqlQuerySpecにはSQL文をそのまま文字列として指定可能です。

SQLのパラメータ化

上記FindById()ではSQL文字列にパラメータ部分も埋め込みを行いました。パラメータ部に対して、明確にパラメータオブジェクト(SqlParameter)を適用することも可能です(リスト 24)。

リスト 24
public RoomReservationInfo FindByIdWithParam(string room, string id)
{
  SqlQuerySpec sqlQuerySpec = new SqlQuerySpec();
  sqlQuerySpec.QueryText = "SELECT * FROM root WHERE root.Room = @room AND root.id = @id";
  sqlQuerySpec.Parameters.Add(new SqlParameter("@room", room));
  sqlQuerySpec.Parameters.Add(new SqlParameter("@id", id));

  var query =
    this.client.CreateDocumentQuery<RoomReservationInfo>(
      UriFactory.CreateDocumentCollectionUri(DocumentDbManager.DatabaseId, DocumentDbManager.CollectionId),
      sqlQuerySpec);

  var list = query.ToList();

  return list.Count > 0 ? list[0] : null;
}

3.5 ドキュメントの削除

ドキュメントの削除は「DocumentClient.DeleteDocumentAsync()メソッド」で行うことが出来ます。
DocumentDbManagerに以下のメソッドを追加します(リスト 25)。
コレクションにパーティションキーを設定している場合は、RequestOptions.PartitionKeyに削除対象ドキュメントのパーティションキー値を設定します。

リスト 25
public async Task<Document> DeleteById(string room, string id)
{
  RequestOptions requestOptions = new RequestOptions();
  requestOptions.PartitionKey = new PartitionKey(room);

  var document = await this.client.DeleteDocumentAsync(
    UriFactory.CreateDocumentUri(
      DocumentDbManager.DatabaseId, 
      DocumentDbManager.CollectionId, 
      id),
    requestOptions);

  return document;
}
リスト 26
// パーティションキーの設定がないコレクションの場合は
// RequestOptions.PartitionKey指定なしの以下の実装が可能
public async Task<Document> DeleteById(string id)
{
  var document = await this.client.DeleteDocumentAsync(
    UriFactory.CreateDocumentUri(
      DocumentDbManager.DatabaseId, 
      DocumentDbManager.CollectionId, 
      id));

  return document;
}

3.6 つづく・・・

DocumentDB編をこの1つの投稿で終わらせようと思っていたのですが、思いのほか長くなってきましたので、「Azure Cosmos DB入門(4)」に分割継続させることにしました。

3.7 資料

本記事のサンプルは以下からダウンロード可能です。

github.com

<< Azure Cosmos DB入門(2)へ | Azure Cosmos DB入門(4)へ >>

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入門(3)へ >>

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入門(2)へ

Azure Cosmos DB入門(目次)

「Azure Cosmos DB入門」目次

DocumentDB が Azure Cosmos DB としてリニューアルされたので、改めてこのサービスの全体像を整理したコンテンツ(ブログ)を「Cosmos DB入門」として書こうと思います。
目次は以下の通り。順次コンテンツを追加していく予定です。
※ 2017/07/09に(1)~(8)のコンテンツが揃いました。今後も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プログラミング ~ DocumentDB編(前編)
      • 3.1 まず、はじめに
      • 3.2 準備
      • 3.3 ドキュメントの作成・更新
      • 3.4 ドキュメントの検索
      • 3.5 ドキュメントの削除
      • 3.6 つづく・・・
      • 3.7 資料
  • Azure CosmosDB入門(4)
    • 4 Cosmos DBプログラミング ~ DocumentDB編(後編)
      • 4.1 サーバーサイド(データベースサイド)ロジック
      • 4.2 トランザクション
      • 4.3 ストアドプロシージャ
        • 4.3.1 Helloストアドプロシージャ
        • 4.3.2 期間指定で予約を削除するストアドプロシージャ
      • 4.4 トリガー
        • 4.4.1 プリ・トリガー(Pre-Trigger)
        • 4.4.2 ポスト・トリガー(Post-Trigger)
      • 4.5 ユーザー定義関数(UDF)
      • 4.6 資料
  • Azure CosmosDB入門(5)
    • 5 Cosmos DBプログラミング ~ MongoDB編
      • 5.1 はじめに
        • 5.1.1 Cosmos DB(MongoDBデータモデル)とMongoDB
      • 5.2 準備
        • 5.2.1 Cosmos DB(MongoDB)データベースアカウントの作成
        • 5.2.2 Visual Studio 2017プロジェクトの作成
      • 5.3 データベースとコレクションの作成
        • 5.3.1 Azureポータルで作成
        • 5.3.2 プログラムで作成
        • 5.3.3 mongoコマンドで作成
      • 5.4 ドキュメントの作成
        • 5.4.1 ドキュメントの一括投入 ~ Studio 3T for MongoDB(旧Mongo chef)
      • 5.5 ドキュメントの検索
        • 5.5.1 Mongoクエリー言語による検索
        • 5.5.2 LINQによる検索
      • 5.6 ドキュメントの更新
      • 5.7 ドキュメントの削除
      • 5.8 資料
      • 5.9 つづく・・・
  • Azure CosmosDB入門(6)
    • 6 Cosmos DBプログラミング ~ Gremlin編
      • 6.1 はじめに
        • 6.1.1 グラフ データモデルとは
        • 6.1.2 Gremlinとは
      • 6.2 準備
        • 6.2.1 Cosmos DB(Gremlin)データベースアカウントの作成
        • 6.2.2 Visual Studio 2017プロジェクトの作成
      • 6.3 データベースとコレクションの作成
        • 6.3.1 プログラムで作成
      • 6.4 グラフデータの操作
        • 6.4.1 Vertexの追加
        • 6.4.2 Edgeの追加
        • 6.4.3 Vertexの検索
        • 6.4.4 Edgeの検索
        • 6.4.5 Vertex~Edge~Vertexを辿る(1)
        • 6.4.6 Vertex~Edge~Vertexを辿る(2)
      • 6.5 まとめ
      • 6.6 資料
  • Azure CosmosDB入門(7)
    • 7 Cosmos DBプログラミング ~ Table編
      • 7.1 はじめに
        • Cosmos DB(Table) と Azure Table Storage
        • アクセス用クラスライブラリ
      • 7.2 Cosmos DB(Table)
        • 7.2.1 データベース構造
        • 7.2.2 Cosmos DB固有情報の設定
      • 7.3 準備
        • 7.3.1 Cosmos DB(Table)データベースアカウントの作成
        • 7.3.2 Visual Studio 2017プロジェクトの作成
      • 7.4 テーブルの作成
        • 7.4.1 Azureポータルでテーブルを作成
        • 7.4.2 コードでテーブルを作成
      • 7.5 テーブルの操作
        • 7.5.1 エンティティ型の定義
        • 7.5.2 エンティティの追加
        • 7.5.3 エンティティの一括追加
        • 7.5.4 エンティティの検索
        • 7.5.5 エンティティの更新
        • 7.5.6 エンティティの削除
      • 7.6 まとめ
      • 7.7 資料
  • Azure CosmosDB入門(8)
    • 8 Cosmos DBをもっと知りたい
      • 8.1 一貫性レベル(Consistency Level)
        • 8.1.1 Cosmos DBの提供する一貫性レベル(Consistency Level)
        • 8.1.2 既定の一貫性レベル
      • 8.2 RU(Request Unit)詳細
        • 8.2.1 RUの設定方法
        • 8.2.2 消費RUの確認方法
        • 8.2.3 RU/s超過時のレスポンスコード429
        • 8.2.4 リトライ動作の指定
        • 8.2.5 一貫性レベル(Consistency Level)による消費RUの違い
        • 8.2.6 RU/m (RU/分) の設定
      • 8.3 グローバルレプリケーション
      • 8.4 障害復旧およびフェールオーバー
        • 8.4.1 自動フェールオーバー
        • 8.4.2 手動フェールオーバー
      • 8.5 まとめ
        ※2017/07/09 目次更新

はじめに

先日 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 Azure Cosmos DB REST response headers | Microsoft Docs