Azure Cosmos DB入門(7)

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

ryuichi111std.hatenablog.com

7 Cosmos DBプログラミング ~ Table編

今回は マルチデータモデルの最後のモデルである Table編 になります。

7.1 はじめに

以前説明したDocumentDB / MongoDBは、共にデータモデルが「ドキュメント データモデル」でした。
今回の Table は、その名前の通り「テーブル型」のデータモデルとなります。
「テーブル型」といってもRDBのテーブルとは異なりますので注意が必要です。あくまでも NoSQL であり、Key-Valueストアのデータとなります。テーブル間のリレーションや、それらをJOINしての操作などはもちろんできません。

「キーとなる値に対して、値(列値の集合)が紐づいている」データ構造、これがCosmos DBの「テーブル」となります。

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

テーブル内の各データ項目は「エンティティ」と呼ばれ、スキーマレスですので、異なるデータ構造のエンティティを同一テーブルに格納することができます。

※Cosmos DB(Table)自体は2017/6/24時点で、まだパブリックプレビュー状態です。

7.1.1 Cosmos DB(Table) と Azure Table Storage

Azureに詳しい方であれば、このようなテーブル構造は「Azure Table Storage」として提供されていたと気が付くのではないかと思います。
その通りであり、機能概要としては同様のサービスとなります。
ただし、Cosmos DBはパフォーマンスに対するSLA、グローバルディストリビューション可能・・・等の特徴を提供します。
Cosmos DB(Table) と Azure Table Storageの特徴比較は以下のようになります。

  • Cosmos DB(Table)
    スループットの予約RUを設定し、待機時間の非常に短いSLA保証されたパフォーマンス実現する。
    グローバルディストリビューションが可能。
    スループット最適化の価格モデル。
    等々・・・

  • Azure Table Storage
    高速だがCosmos DBのようなパフォーマンス保証されたモデルではない。
    単一リージョン構成のサービスである。
    ストレージ最適化の価格モデル。
    等々・・・

つまり、高速な動作・グローバルディストリビューションなどを求める際には「Cosmos DB(Table)」を利用し、それほど高速なスループットは必要なくコストパフォーマンスの良さを求める場合には「Azure Table Storage」を利用する、といった使い分けが可能です。
また、Cosmos DB(Table)のことを、通称「Premiumテーブル」と呼びます。

7.1.2 アクセス用クラスライブラリ

Cosmos DB(Table) と Azure Table Storageは、同様の機能を提供すると説明しました。
従来、Azure Table Storageを利用する場合、アクセスライブラリとして「WindowsAzure.Storage」を利用していました。
Cosmos DB(Table)に接続するライブラリとしては「WindowsAzure.Storage-PremiumTable」というものがNuGetで提供されています。
WindowsAzure.Storage-PremiumTableで提供されるクラス・メソッドのI/Fは、WindowsAzure.Storageと互換となります。
つまり、参照するライブラリを変更するだけで、接続先のテーブルサービスを切り替えることができます。

7.2 Cosmos DB(Table)

Cosmos DB(Table)におけるいくつかのポイントについて説明しておきます。

7.2.1 データベース構造

Cosmos DB(Table)は、他のデータモデルである「ドキュメント(DocumentDB / MongoDB)・グラフ(Gremlin)」と比較して少し異なる部分があります。
それは Azure Table Storage との相互互換性を維持した仕様の影響です。この点は、特に 良い悪い の話ではないので仕様を理解して利用していけば良い点です。
大きな2点について以下に説明します。

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

DocumentDB/MongoDB/Gremlinにおけるデータモデルでは、以下の図に示すようなデータベース構造でした。

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

つまり、「1つのデータベースアカウント」に対して「複数のデータベース」が紐づき、さらに各データベースに対して「複数のコレクション」が紐づきます。
そして、ドキュメントはコレクションに紐づきます。

対して Tableデータモデル では、以下の図のような構造になります。

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

1つのデータベースアカウントには「1つのデータベース」が紐づきます。1つのデータベースに対しては「複数のテーブル」が紐づきます。
1つのデータアイテム(データ項目)は「エンティティ」と呼ばれ、各テーブルに紐づきます。

(2) PartitionKeyは必須

DocumentDB/MongoDB/Gremlinでは「PartitionKey(パーティションキー)」の指定は任意でした(ある程度の規模のシステムを想定した場合には、設定したうえでの運用が現実的です)。
Tableでは PartitionKey の指定が必須になっています。元々 Azure Table Storage において、エンティティをユニークに識別するキーとして「PartitionKey + RowKey」の2つのキーを利用するという仕様に合わせる形になります。
Cosmos DB(Table)においても PartitionKey と RowKey の2つのキーによりテーブル内のエンティティをユニークに識別します。
PartitionKey と RowKey は共に string型 となります。

7.2.2 Cosmos DB固有情報の設定

すでに説明した通り、「Cosmos DB(Table)」と「Azure Table Storage」へのアクセス用クラスライブラリは互換のI/Fが提供されます。
しかし、Cosmos DB(Table)固有の設定機能が存在します。例えば「接続方式(HTTPS or TCP)」であったり、「スループット(RU)の指定」であったり、「一貫性レベルの指定(Consistency Level)」であったりです。
DocumentDB・MongoDB・Gramlin APIモデルにおいては、ロジック上のパラメータ等で設定が可能でした。しかしテーブルAPIモデルでは、構成ファイル(.config)で各種設定を行います。
これは従来のAzure Table Storage用ライブラリとのI/F互換を保つためです。Cosmos DB側ライブラリに固有のプロパティやオプション引数を追加してしまうと、ユーザープロジェクトが参照するライブラリの切り替えのみによる、利用Azureサービスの切り替えができなくなってしまうためです。

構成ファイルで指定可能な設定項目は以下の通りです。

キー 説明
TableConnectionMode 接続モードの指定です。Direct モードと Gateway モードから選択。既定値の Direct の方がパフォーマンス良い。
TableConnectionProtocol 接続プロトコルの指定です。HttpsTcp から選択。Tcpの方がパフォーマンスが良い。
TablePreferredLocations 読み取りリージョンの優先場所をカンマ区切りで指定します。複数リージョンにグローバルレプリカした場合に利用します。
TableConsistencyLevel 一貫性レベルを指定します。Strong / Session / Bounded-Staleness / ConsistentPrefix / Eventual
TableThroughput 予約するスループット(RU)を指定します。テーブル作成時にこの値が適用されます。既定値は 400 です。
TableIndexingPolicy インデックスポリシーを指定します。何も指定しないと、すべての列にインデックスが作成されます。
TableQueryMaxItemCount クエリー時に1回のラウンドトリップで返却される最大項目数を指定します。既定値は -1 でCosmos DBにより自動で最適化されます。
TableQueryEnableScan クエリーがンデックスを使用できない場合、スキャンを使用して実行するかどうかを指定します。 既定値は false です。
TableQueryMaxDegreeOfParallelism クロスパーティションクエリーを行う際の並列処理次数を指定します。0 = プリフェッチなしの直列処理、1 = プリフェッチありの直列処理、2以上 = 指定次数による並列処理。既定値は -1 でCosmos DBにより自動で決定されます。

※ 接続に関しては「TableConnectionMode=Direct / TableConnectionProtoco=Tcp」とする事がパフォーマンスにとって最適です。「ローカル開発実行 & Azure Cosmos DB接続」の際に、Fiddler で通信内容を確認する場合には「TableConnectionMode=Gateway / TableConnectionProtoco=Https」が有効です。送受信されるHTTPから「冗長な呼び出しが行われる実装になっていないか?」「消費RUがどれくらいか?」等の確認が可能です。

7.3 準備

具体的なプログラミングの説明に入るために、まず、AzureへのCosmos DB(Table)の準備と、Visual Studioプロジェクトの準備を行います。

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

Azureポータルを利用して「Cosmos DB(Table)」を作成します。
Azureポータルをブラウザで表示し「新規」を選択。検索テキストボックスに「Azure Cosmos DB」と入力します。

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

以上でプロジェクトの準備が整いました。

7.4 テーブルの作成

具体的な「Cosmos DB(Table)」(Premiumテーブル)の操作を行う実装に移ります。
まずテーブルを作成しますが、「Azureポータル上でGUI操作により作成する方法」と「コードで作成する方法」を順に説明します。

7.4.1 Azureポータルでテーブルを作成

Azureポータルからテーブルを作成し、エンティティを追加するまでの操作を説明します。
Azureポータルで対象Cosmos DBアカウントを表示し、「データエクスプローラ」メニューを選択します。
「New Table」をクリックすると右側に「Add Table」ペインが表示されます。
テーブルID、ストレージ容量、スループット、RU/m の指定を行いOKボタンをクリックします。
ここでは「テーブルID=PersonTable、ストレージ容量=10GB、スループット=400、RU/m=OFF」としました。
f:id:daigo-knowlbo:20170624104829p:plain

以下のように、作成されたテーブルを確認することができます。
データベース名は固定で「TablesDB」となります。
テーブルIDは指定した「PersonTable」となりました。

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

続いて「Add Entity」をクリックします。

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

「Add Table Entity」ペインが表示されるので、エンティティの項目名と価を入力します。
PartitionKeyとRowKeyは必須の固定項目です。それ以降の項目値については「Add Property」をクリックすることで任意の数だけ増やすことができます。

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

作成されたエンティティは以下の通りです。

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


1点DateTime型について注意が必要です。
ここではDateTime型の「Bitrth」項目を定義しました。Add Table EntityのUI上では 2000/9/16 00:00:00 を設定しましたが、追加後の一覧画面では 2000/9/14 21:00:00 となっています。しかし、実際のデータ(本当に登録された値)は 2000/9/15 06:00:00 となっています(一覧からエンティティをダブルクリックしてEdit Table Entity画面を表示すると確認可能)。
DateTime型はCosmos DB内部ではGMT時間で管理されます。しかしAzureポータル上では、日本の+9h処理がおかしいようでこのようなズレが発生しています。
設定した2000/9/16 00:00:00(日本時間)に対して、なぜか -18h された2000/9/15 06:00:00がデータとして登録されてしまいます。さらに2000/9/15 06:00:00を一覧画面で表示する際に -9h した2000/9/14 21:00:00が表示されてしまいます。
コードで登録する際はこの問題は発生しません。


もう1度「Add Entity」をクリックして、更にエンティティを追加しましょう。
今度は先程のエンティティで設定した項目に加えて「Email」という項目を増やしました。

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

2つ目のエンティティにのみ Email項目 が追加されているのを確認することができます。
つまり、異なるスキーマのエンティティを同一テーブルに格納することができる「スキーマレス テーブル」ということを確認することができます。

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

次はコードでテーブルを作成する処理に移ります。
その為、一旦 今作成したTablesDBを削除しておきましょう。
TablesDB右側の「…」をクリックするとポップアップメニューが表示されるので、「Delete Database」を選択します。

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

7.4.2 コードでテーブルを作成

プログラムコードでテーブルを作成する手順を説明します。

(1) 構成ファイル(.config)の定義

前述の通り、DocumentDBなどの場合と異なり、接続に関する設定は構成ファイル(.config)で行います。
ここでは以下に示すように、「接続モード(TableConnectionMode)」「接続プロトコル(TableConnectionProtocol)」「スループット(TableThroughput)」を指定しました(リスト1)。
また、TableConnectionStringキーとして接続文字列も定義しています。

リスト1 app.config

<?xml version="1.0" encoding="utf-8" ?>
<configuration>
  <startup> 
    <supportedRuntime version="v4.0" sku=".NETFramework,Version=v4.6.1" />
  </startup>

  <appSettings>
    <add key="TableConnectionString" value="【Azureポータル→接続文字列から取得】" />
    
    <!-- Client options -->
    <add key="TableConnectionMode" value="Direct"/>
    <add key="TableConnectionProtocol" value="Tcp"/>

    <!--Table creation options -->
    <add key="TableThroughput" value="500"/>

  </appSettings>
</configuration>

(2) テーブル作成処理の実装

テーブル作成処理の実装を行います。
まず、「WindowsAzure.Storage-PremiumTable」パッケージで提供されるクラスライブラリにおける、Table操作の大きな流れは以下のようになります。

  1. アカウントオブジェクト(CloudStorageAccount)を作成
  2. テーブルクライアント(CloudTableClient)を作成
  3. テーブルオブジェクト(CloudTable)を作成
  4. 操作オブジェクトを作成
  5. テーブルオブジェクトに操作オブジェクトを渡して実行

テーブルの作成では「1~3+α」の処理となります。具体的な実装は以下のリスト2になります。

リスト2 // テーブル作成のコードスニペット

// app.configから接続文字列を取得
string connectionString = 
  ConfigurationManager.AppSettings["TableConnectionString"];
//  アカウントオブジェクトを取得
CloudStorageAccount cloudStorageAccount = 
  CloudStorageAccount.Parse(connectionString);
// テーブルクライアントオブジェクトを取得
CloudTableClient cloudTableClient = 
  this.cloudStorageAccount.CreateCloudTableClient();
// テーブルオブジェクトを取得
CloudTable table = 
  this.cloudTableClient.GetTableReference("Person");
// 存在しなかったら作成
table.CreateIfNotExists();

今回のプロジェクトでは、テーブルデータを操作する処理を TableManagerクラス にまとめたいと思います。
そこで、プロジェクトに「TableManagerクラス(TableManager.cs)」を追加します。

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

リスト2を踏まえた TableManagerクラス の実装がリスト3です。

リスト3 TableManager.cs

using System.Configuration;

using Microsoft.WindowsAzure.Storage;
using Microsoft.WindowsAzure.Storage.Table;

namespace CosmosTableExample
{
  public class TableManager
  {
    private CloudStorageAccount cloudStorageAccount = null;
    private CloudTableClient cloudTableClient = null;

    // コンストラクタ
    public TableManager()
    {
      string connectionString = ConfigurationManager.AppSettings["TableConnectionString"];

      this.cloudStorageAccount = CloudStorageAccount.Parse(connectionString);
      this.cloudTableClient = this.cloudStorageAccount.CreateCloudTableClient();

    }

    // テーブルを作成します
    public bool CreateTable(string tableName)
    {
      bool result = false;

      CloudTable table = this.cloudTableClient.GetTableReference(tableName);
      result = table.CreateIfNotExists();

      return result;
    }
  }
}

CloudStorageAccount / CloudTableClientは今後追加する処理でも使いまわす為にフィールド変数として定義しています。更に、この2つのフィールド変数はコンストラクタで初期化するように実装しています。
テーブルを作成する処理は「CreateTable()メソッド」として実装しました。引数でテーブル名を指定できる形にしています。 テーブルの作成処理には、CloudTable.CreateIfNotExists()メソッドを呼び出していますが、非同期処理版の CreateIfNotExistsAsync()メソッド もライブラリで提供されています。

TableManager.CreateTable()の呼び出しの実装はリスト4となります。

リスト4 Program.cs

var tableManager = new TableManager();
tableManager.CreateTable("PersonTable");

リスト4実行後のAzureポータル画面は以下の通りです。

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

データベース名は固定で「TablesDB」となります。
その下に PersonTable テーブルが追加されているのを確認することができます。
また、app.configの TableThroughput で指定した通り、Througput(RU)は 500 となっています。

7.5 テーブルの操作

作成したテーブル(TablesDB -> PersonTable)に対してエンティティの追加・検索・更新・削除の操作を行っていきます。

7.5.1 エンティティ型の定義

まず、テーブルに追加するエンティティの型を定義します。
エンティティ型は、C#クラスで定義することができます。
ここでは Personクラス をエンティティとして定義します(リスト5)。

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

リスト5 Person.cs
using System;

using Microsoft.WindowsAzure.Storage.Table;

namespace CosmosTableExample
{
  public class Person : TableEntity
  {
    public Person() { }

    public Person(string country, string personId)
    {
      this.PartitionKey = country;
      this.RowKey = personId;
    }

    public string FirstName { get; set; }

    public string LastName { get; set; }

    public string Email { get; set; }

    public DateTime Birth { get; set; }
  }
}

Tableに保存するエンティティは、「Microsoft.WindowsAzure.Storage.Table.TableEntityクラス」を継承して定義します。
プロパティとして「FirstName / LastName / Email / Birth」を持たせました。
引数付きコンストラクタにおいて以下の処理を行っています。

  • country(国籍)を PartitionKey として設定
  • personId を RowKey として設定

PartitionKey / RowKeyは、プロパティ名を指定して設定するものではなく、TableEntityクラスにおいて定義された PartitionKeyプロパティ / RowKeyプロパティ に対して値そのものに設定します。

7.5.2 エンティティの追加

Personエンティティを PersonTable に追加する実装を行います。
TableManagerクラスに InsertPerson() メソッドを追加します(リスト6)。

リスト6 TableManager.cs

public bool InsertPerson(string tableName, Person person)
{
  // テーブルオブジェクトを取得
  CloudTable table = this.cloudTableClient.GetTableReference(tableName);
  // INSERT操作オブジェクトを取得
  TableOperation insertOperation = TableOperation.Insert(person);
  // 実行
  TableResult result = table.Execute(insertOperation);

  return true;
}
  • TableOperationオブジェクト
    Microsoft.WindowsAzure.Storage.Table.TableOperationクラスは、テーブルに対する操作を表すオブジェクトです。
    TableOperationクラスには、各操作に対応したTableOperationオブジェクトを生成するstaticメソッドが用意されています。
    今回はエンティティの追加、つまりINSERT操作を行う為、TableOperation.Insert()メソッドにより「INSERT処理を行うTableOperationオブジェクト」を作成します。

  • CloudTable.Execute()
    TableOperationオブジェクトは「テーブルに対する操作を表すオブジェクト」であり、具体的にCosmos DBサーバーに対して処理を行うわけではありません。
    CloudTableオブジェクトの「Execute()メソッド」を呼び出すことで、Cosmos DBに対する具体的な処理が実行されます。

  • TableResultオブジェクト
    処理結果がTableResultオブジェクトとして返却されます。

TableManager.InsertPerson()の呼び出し元の実装はリスト7となります。

リスト7 Program.cs TableManager.InsertPerson()呼び出し元

TableManager tableManager = new TableManager();

Person person = new Person("Japan", "0000000001")
{
  FirstName = "Ryuichi",
  LastName = "Daigo",
  Birth = new DateTime(2000, 9, 16),
  Email = "daigo@clearboxtechnology.net"
};

tableManager.InsertPerson("PersonTable", person);

また、リスト6の「TableResult result = table.Execute(insertOperation);」が実行された際の、resultの値をウォッチで確認した画面が以下です。

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

処理の結果として作成されたエンティティの Etag、HTTPステータスコード204 を確認することができます。
ETagは、Cosmos DBが各リソース(今回のケースではエンティティ)に対して自動的に割り当てる値です。同時実行制御に利用され、リソース(エンティティ)が更新されるたびに自動で更新されます。
204はHTTPレスポンスコード「204 No Content」に該当します。

追加されたPersonエンティティの内容をAzureポータルで確認したものが以下です。

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

先程も説明しましたが、一覧におけるDateTime表示の問題により、Birthの値の表示がおかしくなっています。
2000/9/16 00:00:00(日本時間)として登録したデータは、Cosmos DB上では 2000/9/15 15:00:00(GMT) として登録されます。
以下はエンティティをダブルクリックして Edit Table Entity 表示で確認した画面です。
f:id:daigo-knowlbo:20170624172939p:plain

RowKeyが未設定の場合

テーブルにエンティティを追加する際、PartitionKeyの設定は必須です。一方 RowKey は未設定でエンティティ作成を行うことが可能です(リスト8)。

リスト8 Program.cs

TableManager tableManager = new TableManager();

Person person = new Person("Japan", "") // RowKeyをから文字列に設定
{
  FirstName = "Ryuichi",
  LastName = "Daigo",
  Birth = new DateTime(2000, 9, 16),
  Email = "daigo@clearboxtechnology.net"
};

tableManager.InsertPerson("PersonTable", person);

上記コードでエンティティ登録を行ったエンティティを、Azureポータルで確認したのが以下の画面です。

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

RowKeyは、Cosmos DBのシステム上では必須項目なので、自動でGUIDが割り当てられました。

7.5.3 エンティティの一括追加

バッチで一括でテーブル操作を行う為の「TableBatchOperationクラス」というものが用意されています。
TableBatchOperationを使用することで、エンティティの一括追加処理を実装することができます。
TableManagerクラスに、複数のPersonを一括INSERTするメソッドInsertPersonBatch()を実装したのがリスト9です。

リスト9 TableManager.cs

public bool InsertPersonBatch(string tableName, List<Person> persons)
{
  // テーブルオブジェクトを取得
  CloudTable table = this.cloudTableClient.GetTableReference(tableName);

  // バッチ操作オブジェクトを作成
  TableBatchOperation tableBatchOperation = new TableBatchOperation();

  // INSERT操作オブジェクトを作成
  foreach (Person person in persons)
  {
    TableOperation insertOperation = TableOperation.Insert(person);
    tableBatchOperation.Add(insertOperation);
  }

  // バッチ実行
  IList<TableResult> results = table.ExecuteBatch(tableBatchOperation);

  return true;
}
  • TableBatchOperation と ExecuteBatch
    TableBatchOperationは new 演算子インスタンス化します。
    バッチ処理したい操作、つまりTableOperationオブジェクトを、Add()することができます。
    一度にバッチ化することができるオペレーション数は 100 です。
    また、同一 PartitionKey を持つエンティティに対するバッチ操作のみが許可されています。異なる PartitionKey を持つエンティティの操作をバッチ化することはできません。
    作成したTableBatchOperationオブジェクトをCloudTable.ExecuteBatch()メソッドの引数に設定することで一括操作を行うことができます。

適当なPersonオブジェクトリストを作成して、リスト9のTableManager.InsertPersonBatch()を呼び出す実装が以下のリスト10です。
PartitionKeyとなる Country 毎に100件のPersonオブジェクトを作成して、一括追加処理を行っています。
名前や生年月日はRandomクラスを使用して適当な値になるようにしています。
また、InsertPersonBatch() を呼び出すたびに 1,500ms スリープしています。これは、ソース中のコメントにもあるように、今回のテーブルの予約RUである500RUを超えないように配慮した実装となります。
(あくまでもサンプルなので、実運用コードではSleepしないようにしてください。スレッドがロックされてしまいます・・・)

リスト10 Program.cs

TableManager tableManager = new TableManager();

List<Person> persons = new List<Person>();

string[] country = { "Japan", "UnitedKingdom", "America", "France", "Porland" };
Random random = new Random();
int totalIndex = 0;
for (int i = 0; i < 5; i++)
{
  persons.Clear();

  for (int n = 0; n < 100; n++)
  {
    Person person = new Person(country[i], string.Format("{0:1000000000}", totalIndex))
    {
      FirstName = "FirstTest" + totalIndex.ToString(),
      LastName = "LastTest" + totalIndex.ToString(),
      Birth = new DateTime(random.Next(1960, 2010), random.Next(1, 12), random.Next(1, 28)),
      Email = string.Format("test{0}@test.jp", totalIndex)
    };

    persons.Add(person);
    totalIndex++;
  }

  tableManager.InsertPersonBatch("PersonTable", persons);

  // 予約RUをオーバーするので一旦スリープ
  System.Threading.Thread.Sleep(1500);
}

作成されたエンティティリストは、以下の画面のようになります。

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

7.5.4 エンティティの検索

ここまでの手順を行った場合、PersonTableには500件以上のPersonエンティティが登録された状態となっているはずです。
次にこれらのデータを検索する処理を実装します。

その前にapp.configを少し修正しておきたいと思います。
以下のように設定を変更し、HTTPSで通信が行われるように変更します。

app.config

<!-- Client options -->
<add key="TableConnectionMode" value="Gateway"/>
<add key="TableConnectionProtocol" value="Https"/>

これは、バックエンドにおけるクエリー操作の動きを確認する為です。

(1) PartitionKeyとRowKeyを指定した検索

PartitionKeyとRowKeyを指定した検索を行います。
テーブル内でコレクションをユニークに識別する2項目によるクエリーです。
つまり、単一エンティティの抽出になります。

TableManagerクラスに「FindByPartitionKeyAndRowKey()メソッド」を追加します(リスト11)。

リスト11 TableManager.cs

public async Task<Person> FindByPartitionKeyAndRowKey(
    string tableName, string partitionKey, string rowKey)
{
  // テーブルオブジェクトを取得
  CloudTable table = this.cloudTableClient.GetTableReference(tableName);

  // テーブルオペレーションを作成
  TableOperation tableOperation = TableOperation.Retrieve<Person>(partitionKey, rowKey);

  // クエリー実行
  TableResult person = await table.ExecuteAsync(tableOperation);

  if (person.Result != null)
    return person.Result as Person;

  return null;
}
  • TableOperation.Retrieve()
    TableOperation.Retrieve()は、PartitionKeyとRowKeyを指定したクエリーオペレーションオブジェクト(TableOperation)を返却します。

  • CloudTable.ExecuteAsync()
    TableOperationオブジェクトを引数にしてCloudTable.ExecuteAsync()を呼び出すことでクエリーが実行されます。
    戻り値の型は「TableResult」となります。TableResult.Resultプロパティに取得したPersonオブジェクトが格納されています。

TableManager.FindByPartitionKeyAndRowKey()の呼び出しコードは以下の通りです(リスト12)。

リスト12 Program.cs
var person = await this.tableManager.FindByPartitionKeyAndRowKey("PersonTable", "America", "1000000296");

// 結果をコンソール出力
Console.WriteLine("FindByKey(\"PersonTable\", \"America\", \"1000000296\")の結果");
Console.WriteLine(
  string.Format("PartitionKey={0} RowKey={1} FirstNam={2} LastName={3} EMail={4} Birth={5}",
  person.PartitionKey, person.RowKey, person.FirstName, person.LastName, person.Email, person.Birth.ToString("yyyy/MM/dd"))
  );
行われたHTTPS通信

リスト12が実行された際(厳密にはリスト11の「TableResult person = await table.ExecuteAsync(tableOperation);」が実行された際)のHTTPSリクエストとレスポンスをFiddlerで監視したものが以下です。

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

  • URI
    URIでドキュメントのRowKeyを指定しています。

  • HTTP Request Headerの x-ms-documentdb-partitionkey
    x-ms-documentdb-partitionkeyにPartitionKeyを指定しています。
    つまり、リクエストするURI と x-ms-documentdb-partitionkey により取得対象のエンティティを指定しています。

  • HTTP Reqponse Headerの x-ms-request-charge
    処理に要したRUは 1 でした。

  • HTTP Response Body
    ボディに取得されたエンティティがJSON形式で返却されています。

(2) PartitionKeyを指定した検索

PartitionKeyを指定した検索を行います。
RowKeyの指定は行わないので、複数のエンティティが取得対象となります。
単一クエリー条件による検索であり、PartitionKey以外の項目をクエリー条件にする場合も同様の方法をとることができます。

リスト13 TableManager.cs
public async Task<List<Person>> FindByPartitionKey(string tableName, string partitionKey)
{
  // テーブルオブジェクトを取得
  CloudTable table = this.cloudTableClient.GetTableReference(tableName);

  // クエリーオブジェクトを作成
  TableQuery<Person> query = new TableQuery<Person>()
    .Where(
    TableQuery.GenerateFilterCondition(
      "PartitionKey", 
      QueryComparisons.Equal, 
      partitionKey)
    );

  // 実行
  var persons = await table.ExecuteQuerySegmentedAsync<Person>(query, null);

  return persons.Results;
}
  • TableQueryオブジェクト
    クエリーを表すTableQueryオブジェクトを作成します。
    今回は抽出条件を追加したいので、Where()メソッドを呼び出します。
    文字列データの比較を行う場合は「TableQuery.GenerateFilterCondition()」メソッドを呼び出します。
    GenerateFilterCondition()は、引数に応じた条件指定文字列をstring型で返却します。
    リスト13の呼び出しでは、単純に「PartitionKey eq ‘【partitionKey変数の値】'」となります。

  • CloudTable.ExecuteQuerySegmentedAsync()
    TableQueryオブジェクトを引数として、ExecuteQuerySegmentedAsync()メソッドを呼び出すことでクエリーが実行されます。

TableManager.FindByPartitionKey()の呼び出しコードは以下の通りです(リスト14)。

リスト14 Program.cs

var persons = await this.tableManager.FindByPartitionKey("PersonTable", "Japan");

// 結果をコンソール出力
Console.WriteLine("FindByPartitionKey(\"PersonTable\", \"Japan\")の結果");
foreach (var person in persons)
{
  Console.WriteLine(
    string.Format("PartitionKey={0} RowKey={1} FirstNam={2} LastName={3} EMail={4} Birth={5}",
    person.PartitionKey, person.RowKey, person.FirstName, person.LastName, person.Email, person.Birth.ToString("yyyy/MM/dd"))
    );
}
行われたHTTPS通信

リスト14が実行された際(厳密にはリスト13の「var persons = await table.ExecuteQuerySegmentedAsync(query, null);」が実行された際)のHTTPSリクエストとレスポンスをFiddlerで監視したものが以下です。

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

  • URI
    URIではPersonTableのドキュメントが対象であることのみ示されています。

  • HTTP Request Headerの x-ms-documentdb-query-enablecrosspartition
    x-ms-documentdb-query-enablecrosspartition が True に設定されています。
    クロスパーティション検索(PartitionKeyの異なるデータの検索)を行う際には、DocumentDBでは明示的なパラメータの指定が必要でした。
    しかし、Tableでは無条件にクロスパーティション検索が有効になるようです。
    今回のようにPartitionKeyを条件に加えている際にもTrueとなります。

  • HTTP Request Body
    リクエストボディに以下のようなクエリー文字列が設定されています。
    {“query”:“select * from entity where (entity[‘$pk’] = \"Japan\”)“}

  • HTTP Reqponse Headerの x-ms-item-count
    検索結果が 102 件であったことが示されています。

  • HTTP Reqponse Headerの x-ms-request-charge
    処理に要したRUは 35.08 でした。
    102件のデータを抽出したのでこれくらいの値になったようです。

  • HTTP Response Body
    ボディに取得されたエンティティがJSON形式で返却されています。

(3) 複数の条件による検索(DateTime含む)

最後に、複数条件による検索を行います。
PartitionKeyであるCountry と Birth をクエリー条件とします。
TableManagerクラスにFindByCountryAndBirth()メソッドを追加します(リスト15)。 country(PartitionKey)はイコール演算子の抽出条件とし、birthに関しては指定した年月日以降の値のエンティティを抽出対象とします。

リスト15 TableManager.cs

public async Task<List<Person>> FindByCountryAndBirth(string tableName, string country, DateTime birth)
{
  // テーブルオブジェクトを取得
  CloudTable table = this.cloudTableClient.GetTableReference(tableName);

  // 日付を20桁のTicksに変換
  string sBirth = birth.Ticks.ToString("00000000000000000000");
  
  // テーブルオペレーションを作成
  TableQuery<Person> query = new TableQuery<Person>()
    .Where(
    TableQuery.CombineFilters(
      TableQuery.GenerateFilterCondition("PartitionKey", QueryComparisons.Equal, country),
      TableOperators.And,
      TableQuery.GenerateFilterCondition("Birth", QueryComparisons.GreaterThanOrEqual, sBirth)
      //TableQuery.GenerateFilterConditionForDate("Birth", QueryComparisons.GreaterThanOrEqual, new DateTimeOffset(birth))
      ));
  
  var persons = await table.ExecuteQuerySegmentedAsync<Person>(query, null);

  return persons.Results;
}
  • TableQueryオブジェクト
    先程と同様にTableQueryオブジェクトを作成します。
    今回は抽出条件が複数であるため TableQuery.CombineFilters() メソッドを利用します。このメソッドは、2つの条件式を任意のオペレータ(ANDやORなど)でつなぎ合わせます。
    CombineFiltersオブジェクトをネストさせて、より複雑な条件を指定することも可能です。

  • Birth(DateTime型)の条件指定
    DateTime(日付)のクエリー条件を指定するために「TableQuery.GenerateFilterConditionForDate()」を呼び出したいところですが、これを使うと実行時エラーが発生してしまいます。
    OData.Edmの日付に関する書式が不正になるようなのですが、何故そうなってしまうのかは不明でした・・・
    解決策として、DateTime.Tockの値を20桁の文字列にして Birth と条件比較するとうまく動作することが確認できました。

TableManager.FindByCountryAndBirth()の呼び出しコードは以下の通りです(リスト16)。

リスト16 Program.cs

var persons = await this.tableManager.FindByCountryAndBirth("PersonTable", "France", new DateTime(1990,1,1,0,0,0,DateTimeKind.Utc));

// 結果をコンソール出力
Console.WriteLine("FindByCountryAndBirth(\"PersonTable\", \"France\", new DateTime(1990,1,1))の結果");
foreach (var person in persons)
{
  Console.WriteLine(
    string.Format("PartitionKey={0} RowKey={1} FirstNam={2} LastName={3} EMail={4} Birth={5}",
    person.PartitionKey, person.RowKey, person.FirstName, person.LastName, person.Email, person.Birth.ToString("yyyy/MM/dd"))
    );
}
行われたHTTPS通信

リスト16が実行された際(厳密にはリスト15の「var persons = await table.ExecuteQuerySegmentedAsync(query, null);」が実行された際)のHTTPSリクエストとレスポンスをFiddlerで監視したものが以下です。

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

内容的には「(2) PartitionKeyを指定した検索 -> 行われたHTTPS通信」と同様なので割愛いたします。

7.5.5 エンティティの更新

既存のエンティティを更新する処理を実装します。
TableManagerクラスにReplacePerson()メソッドを追加します(リスト17)。

リスト17 TableManager.cs
public bool ReplatePerson(string tableName, Person person)
{
  // テーブルオブジェクトを取得
  CloudTable table = this.cloudTableClient.GetTableReference(tableName);
  // REPLACE操作オブジェクトを取得
  TableOperation replaceOperation = TableOperation.Replace(person);
  // 実行
  TableResult result = table.Execute(replaceOperation);

  return true;
}

エンティティの更新は、まず既存エンティティを取得し、値を変更の上で更新処理を実行します。
TableManager.ReplacePerson()メソッドの呼び出し例はリスト18となります。

リスト18 Program.cs

// エンティティを取得
Person person = 
  await tableManager.FindByPartitionKeyAndRowKey(
     "PersonTable", 
     "Japan", 
     "0000000001");

// エンティティ値を変更
person.LastName = "Modify!!!";

// エンティティを更新
bool result = tableManager.ReplatePerson("PersonTable", person);

また、"エンティティを取得して"から"更新するまでの間"に、別のプロセス等によりエンティティが更新された場合、更新処理は失敗します。
エンティティの変更が行われると、Cosmos DB内部でエンティティに設定されたETag値の更新が行われます。取得した際のETag値 と 更新する際のETag値 は、同時実行制御として比較されたうえで更新処理が行われます。
非現実的な実装ですが、リスト19のように明示的にETag値を変更すると、同様のエラーを引き起こすことができます。

リスト19 Program.cs

// エンティティを取得
Person person = 
  await tableManager.FindByPartitionKeyAndRowKey(
     "PersonTable", 
     "Japan", 
     "0000000001");

// エンティティ値を変更
person.LastName = "Modify!!!";
person.ETag = Guid.NewGuid().ToString(); // わざとエラーを発生させる

// エンティティを更新
bool result = tableManager.ReplatePerson("PersonTable", person);

【補足】TableOperation.InsertOrReplace()

上記サンプルではTableOperationオブジェクトの作成に「TableOperation.Replace()」を使用しました。
TableOperation.InsertOrReplace()を使用すると、既存エンティティが存在する場合は更新、存在しない場合は新規エンティティ作成を行うオペレーションオブジェクトが作成されます。

7.5.6 エンティティの削除

既存のエンティティを削除する処理を実装します。 TableManagerクラスにDeletePerson()メソッドを追加します(リスト20)。

リスト20 TableManager.cs
public bool DeletePerson(string tableName, Person person)
{
  // テーブルオブジェクトを取得
  CloudTable table = this.cloudTableClient.GetTableReference(tableName);
  // DELETE操作オブジェクトを取得
  TableOperation deleteOperation = TableOperation.Delete(person);
  // 実行
  TableResult result = table.Execute(deleteOperation);

  return true;
}

エンティティの削除は、まず既存エンティティを取得し、値を変更の上で更新処理を実行します。
TableManager.DeletePerson()メソッドの呼び出し例はリスト21となります。

リスト21 Program.cs

// エンティティを取得
Person person = 
  await tableManager.FindByPartitionKeyAndRowKey(
    "PersonTable", 
    "Japan", 
    "0000000001");

// エンティティを削除
bool result = tableManager.DeletePerson("PersonTable", person);

7.7 まとめ

Csomos DB(テーブル)APIが「Azure Table Storage」APIと同一のクラスメソッドI/Fを持っている事は非常に魅力的です。
これにより、共通のプログラムを、用途・コストに合わせて展開することも可能になります。
本投稿ではテーブルの代表的な操作方法のみの説明になりましたが、Cosmos DB(Table)を利用する上での1つのきっかけとなればと思っております。

7.8 資料

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

github.com

<< Azure Cosmos DB入門(6)へ | Azure Cosmos DB入門(8)へ >>(準備中)

Azure Cosmos DB入門(6)

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

ryuichi111std.hatenablog.com

6 Cosmos DBプログラミング ~ Gremlin編

さて、今回は Gremlin編 になります。

6.1 はじめに

以前説明したDocumentDB / MongoDBは、共にデータモデルが「ドキュメント データモデル」でした。
今回のGremlinは、がらりと変わり「グラフ データモデル」になります。

6.1.1 グラフ データモデルとは

グラフデータモデルとは「facebookの友達」「twitterのフォロー」「駅の路線図の繋がり」のような繋がりのデータ構造を指します。
グラフデータモデルを扱うデータベースを、グラフデータベースと呼びます。
例えば、facebookの友達・フォローの関係をグラフデータモデルで表すと以下のようになります。

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

グラフで最も重要な要素として、以下の2つの概念があります。

  • Vertex
    直訳で「頂点」ですね。上の例の図では、「人」「企業」の丸い要素(ノード)が該当します。
    Vertexには「ラベル」を付けることができます。例では「人」「企業」がラベルに該当します。ラベルは、Vertexの種類を表します。
    また、Vertexには任意の数の「プロパティ」を関連付けることができます。例では「名前・生年月日・性別」がプロパティに該当します。

  • Edge
    Edgeは「VertexとVertexを結ぶ線(辺)」を表します。上の例の図では 矢印線 が該当します。
    Edgeにも「ラベル」を付けることができます。例では「友達」「フォロー」「いいね」に該当します。ラベルは、Edgeの種類を表します。
    また、Edgeには任意の数の「プロパティ」を関連付けることができます。例では「いつから?・関係」がプロパティに該当します。

このようなデータ構造はRDBでも表現は可能です。しかし、データ構造上の概念の異なるテーブル形式に保存するため、分かりにくい もしくは 複雑な データ構造として保存されることになります。
また、グラフデータベースのトラバーサル言語であるGremlinを使用すると、以下のような検索を簡単に行うことができます。

  • 高橋さんの友達の友達はだれ?(→下田さん、佐藤さん、上田さん、神山さん)
  • 高橋さんの友達をフォローしている人はだれ?(→木村さん、川田さん)

つまり、「複数の要素のグラフ上の関係性の保持」および「それらの要素の関係性の検索」を容易に行うことができるものが グラフデータベースです。

6.1.2 Gremlinとは

グラフデータベースについて学ぼうとすると「TinkerPop」という言葉が出てきます。
TinkerPopは、現在、Apache Software Foundationのトッププロジェクトであり、「グラフコンピューティング フレームワーク」です。TinkerPopという抽象化層の上に各種ベンダーがグラフデータベースを構築しています。
Cosmos DBも同様にTinkerPopの上に実装されたグラフデータベースという位置づけになります。
また、Cosmos DB以外にも以下のようなグラフデータベースが存在します。

  • Neo4j
  • DESGraph
  • IBM Graph
  • Titan
  • …等々多数

そして、グラフデータベースの利用者(利用アプリケーション)は、「Gremlin traversal language」によって、グラフデータを操作します。
「Gremlin traversal language」は、RDBでいうところの「SQL」に該当します。つまり、グラフデータベースに対して、データを検索する・追加する・更新する、といった操作を行うための言語です。

TinkerPopは元々(Ver.2.xまで)は、以下のようなコンポーネントの組み合わせで構成されていました。

  • Rexster(RESTインターフェイス
  • Furnace(グラフアルゴリズム) / Frames(オブジェクトグラフマッパー)
  • Gremlin(グラフ トラバーサル言語)
  • Pipes(データフロー)
  • BluePrints(プロパティグラフモデル)

Ver.3.xからはこれらが統合され、全体で「Gremlin」と呼ばれるようになっています。
この辺りのお話は、TinkerPopやGremlinに関する少し複雑な部分に入ってしまい、「Azure Cosmos DB入門」と題した本投稿から逸脱(?)しそうなので、詳細は以下の公式ページを参照してください。

tinkerpop.apache.org

6.2 準備

では、具体的なCosmos DB(Gremlin)開発のお話に入るために、Azure上へのCosmos DBの準備と、Visual Studioプロジェクトの準備を行います。

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

Azureポータルを利用して「Cosmos DB(Gremlin(グラフ))」を作成します。
Azureポータルをブラウザで表示し「新規」を選択。検索テキストボックスに「Azure Cosmos DB」と入力します。

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Microsoft.Azure.Graphs」と、その依存関係である「Microsoft.Azure.DocumentDB」がインストールされます。

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

今回のCosmos DB(Gremlin)においても、DocumentDBの場合と同様に「データベースアカウント → データベース → コレクション → ドキュメント(グラフデータ)」というデータモデル構造となります。
まず、データベースとコレクションを作成したいと思います。
作成方法は「Azureポータルで作成」「プログラムで作成」などがあります。
「Azureポータルで作成」「プログラムで作成」の2つの方法は共に、DocumentDBの時とまったく同じ方法となります。

6.3.1 プログラムで作成

本稿では、Gremlin(グラフ)操作を管理するクラスとして GremlinManagerクラス を用意することとします。
プロジェクトに GremlinManagercs を追加します。

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

(1)接続情報の定義

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

リスト1 GremlinManager.cs
private const string EndPoint = "【URI】";
private const string AuthKey = "【認証キー】";
private const string DatabaseId = "BookStoreDb";
private const string CollectionId = "BookStoreCollection";

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

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

データベースIDは「BookStoreDb」、コレクションIDは「BookStoreCollection」としました。これはサンプルとして、オンライン書店のデータベースを想定します。

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

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

リスト2 GremlinManager.cs

// 一般的に利用する名前空間
using System;
using System.Collections.Generic;
using System.Threading.Tasks;

// Cosmos DB(Gremlin)関連の名前空間
using Microsoft.Azure.Documents;
using Microsoft.Azure.Documents.Client;
using Microsoft.Azure.Documents.Linq;
using Microsoft.Azure.Graphs;
using System.Text;

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

CosmosDB(Gremlin)に接続する為の接続クライアント「Microsoft.Azure.Documents.Client.DocumentClient」をGremlinbManagerクラスのフィールド変数「client」として定義します(リスト 3)。
clientはコンストラクタで初期化することとします。

リスト3 GremlinManager.cs

private DocumentClient client = null;
...

public GremlinManager()
{
  this.client = new DocumentClient(
    new Uri(GremlinManager.EndPoint),
    GremlinManager.AuthKey,
    new ConnectionPolicy
    {
      ConnectionMode = ConnectionMode.Direct,
      ConnectionProtocol = Protocol.Tcp
    });
}

(4)データベースとコレクション作成メソッドの追加

GremlinManagerクラスにデータベースを作成するメソッド「CreateDatabase()」、コレクションを作成するメソッド「CreateCollection()」を定義します(リスト 4)。
また、DocumentCollectionオブジェクトは、後々の使い勝手を考慮し、クラスメンバフィールドに保持することとします。

リスト4 GremlinManager.cs

private DocumentCollection collection = null;
...

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

  return database;
}

public async Task<DocumentCollection> CreateCollection()
{
  // パーティションキー指定はなし
  DocumentCollection collection = new DocumentCollection();
  collection.Id = GremlinManager.CollectionId;

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

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

  return collection;
}

以下が GremlinManager.CreateDatabase() / .CreateCollection() 呼び出しコードです(リスト5)。

リスト5 Program.cs

private GremlinManager manager = new GremlinManager();
...
var database = await manager.CreateDatabase();
var collection = await manager.CreateCollection();

上記コードによるデータベース・コレクションの作成を行った結果をAzureポータルで確認したものが以下です。
パーティションキーなし、予約RUは400での作成となります。

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

6.4 グラフデータの操作

BookStoreDb - BookStoreCollection にグラフデータを追加していきます。
このデータベースはオンライン書店を想定します。

以下のようなグラフデータを作成することとします。

【サンプルグラフデータ】 f:id:daigo-knowlbo:20170619024530p:plain

  • Customer Vertex
    Customer Vertexは顧客を表します。
    ラベルは「Customer」、それ以外には「id」のみを持ちます。

  • Book Vertex
    Book Vertexは書籍を表します。
    ラベルは「Book」、「id」の他に「title」「author」のプロパティを持ちます。
    idは書籍の ISBN番号 を使用しています。

  • order Edge
    order Edgeは、Customer(顧客)が購入したBook(書籍)を表します。

6.4.1 Vertexの追加

Vertexの追加は Gremlin では以下のような構文で行います。

ラベルのみ指定してVertexを作成
g.addV('【ラベル】')

ラベルとIDを指定してVertexを作成
g.addV('【ラベル】').property('id', '【ID】')

GremlinManagerクラスに、上記 Gremlin構文 を実行する「AddVertex()」メソッドを追加します(リスト6)。

リスト6 GremlinManager.cs

// ラベルとidを指定してVertexを追加
public async Task<bool> AddVertex(string label, string id)
{
  string gr = 
    string.Format("g.addV('{0}').property('id', '{1}')", label, id);

  var ret = 
    await this.client.CreateGremlinQuery<dynamic>(
      this.collection,gr)
    .ExecuteNextAsync();

  return true;
}
  • CreateGremlinQuery()メソッド
    DocumentClient.CreateGremlinQuery()メソッドを呼び出すことでGremlinクエリーオブジェクトを作成することができます。
    CreateGremlinQuery()メソッドは、DocumentClientクラスの拡張メソッドで、Microsoft.Azure.Graphsで実装されています。

また、Bookは id 以外のプロパティ(title / author)を追加します。
リスト7に示すような「SetProperty()」、もう1つの「AddVertex()」オーバーロードメソッドをGremlinManagerクラスに追加しました。

リスト7 GremlinManager.cs

// idを持つVertexにプロパティを追加します
public async Task<bool> SetProperties(string id, Dictionary<string, string> properties)
{
  StringBuilder grSb = new StringBuilder(
    string.Format("g.V('{0}')", id));
  foreach (string key in properties.Keys)
  {
    grSb.Append(string.Format(".property('{0}', '{1}')", key, properties[key]));
  }

  var ret =
    await this.client.CreateGremlinQuery<dynamic>(
      this.collection, grSb.ToString())
    .ExecuteNextAsync();

  return true;
}

// Vertexの作成とプロパティ設定同時に行います
public async Task<bool> AddVertex(string label, string id, Dictionary<string, string> properties)
{
  StringBuilder grSb = new StringBuilder(
    string.Format("g.addV('{0}').property('id', '{1}')", label, id));
  foreach (string key in properties.Keys)
  {
    grSb.Append(string.Format(".addProperty('{0}', '{1}')", key, properties[key]));
  }

  var ret =
    await this.client.CreateGremlinQuery<dynamic>(
      this.collection, grSb.ToString())
    .ExecuteNextAsync();

  return true;
}

GremlinManager.AddVertex() / SetProperties() を呼び出して、4つのCustomer Vertex と 5つのBook Vertex を作成する処理はリスト8の通りです。

リスト8 Program.cs

GremlinManager manager = new GremlinManager();

// add Customer Vertex
var cv1 = await manager.AddVertex("Customer", "daigo");
var cv2 = await manager.AddVertex("Customer", "tanaka");
var cv3 = await manager.AddVertex("Customer", "kido");
var cv4 = await manager.AddVertex("Customer", "sakai");

// add Book Vertex
// AddVertex() + SetProperties()呼び出し
var bv1 = await manager.AddVertex("Book", "978-4101339115");
var prop1 = new Dictionary<string, string>();
prop1.Add("title", "きらきらひかる");
prop1.Add("author", "江國香織");
var bp1 = await manager.SetProperties("978-4101339115", prop1);

// もう1つのAddVertex()オーバーロード呼び出し
var prop2 = new Dictionary<string, string>();
prop2.Add("title", "流しのしたの骨");
prop2.Add("author", "江國香織");
var bv2 = await manager.AddVertex("Book", "978-4101339153", prop2);

var prop3 = new Dictionary<string, string>();
prop3.Add("title", "キッチン");
prop3.Add("author", "吉本ばなな");
var bv3 = await manager.AddVertex("Book", "978-4041800089", prop3);

var prop4 = new Dictionary<string, string>();
prop4.Add("title", "月に吠える");
prop4.Add("author", "萩原朔太郎");
var bv4 = await manager.AddVertex("Book", "978-4903620510", prop4);

var prop5 = new Dictionary<string, string>();
prop5.Add("title", "抱擁、あるいはライスには塩を");
prop5.Add("author", "江國香織");
var bv5 = await manager.AddVertex("Book", "978-4087713664", prop5);

6.4.2 Edgeの追加

続いて Customer / Book をつなげる Edge を追加します。

まず、Gremlinにおける Edge追加構文は以下の通りです。

【FROMのVertex】.addE('【ラベル】').to(【ToのVertex】)

例)g.V('daigo').addE('order').to(g.V('978-4101339115'))

GremlinManagerクラス にEdgeを作成する「AddEdge()」メソッドを追加します(リスト9)。

リスト9 GremlinManager.cs

public async Task<bool> AddEdge(string label, string fromId, string toId)
{
  string gr = string.Format(
    "g.V('{0}').addE('{1}').to(g.V('{2}'))", 
    fromId, label, toId);

  var ret =
    await this.client.CreateGremlinQuery<dynamic>(
      this.collection, gr)
    .ExecuteNextAsync();

  return true;
}

GremlinManager.AddEdge()を呼び出してサンプルとなる Customer / Book Vertexに対するEdgeを追加する処理を実装します(リスト10)。

リスト10 Program.cs

// add order Edge
// daigo -> きらきらひかる
var e1 = await manager.AddEdge("order", "daigo", "978-4101339115");
// tanaka -> きらきらひかる
var e2 = await manager.AddEdge("order", "tanaka", "978-4101339115");
// tanaka -> 月に吠える
var e3 = await manager.AddEdge("order", "tanaka", "978-4903620510");
// sasaki -> キッチン
var e4 = await manager.AddEdge("order", "sasaki", "978-4041800089");
// sasaki -> 流しのしたの骨
var e5 = await manager.AddEdge("order", "sasaki", "978-4101339153");
// kido -> きらきらひかる
var e6 = await manager.AddEdge("order", "kido", "978-4101339115");
// kido -> 抱擁、あるいはライスには塩を
var e7 = await manager.AddEdge("order", "kido", "978-4087713664");
// kido -> 流しのしたの骨
var e8 = await manager.AddEdge("order", "kido", "978-4101339153");

以上で【サンプルグラフデータ】の作成が完了しました。

6.4.3 Vertexの検索

作成したグラフに対する検索を行います。

(1) すべてのVertexを一覧

まずは すべてのVertex を一覧してみます。

Gremlin構文は以下となります。

g.V()

GremlinManagerクラスにGetAllVertex()メソッドを追加します(リスト11)。

リスト11 GremlinManager.cs

public async Task<List<dynamic>> GetAllVertex()
{
  List<dynamic> result = new List<dynamic>();

  var query =
    this.client.CreateGremlinQuery<dynamic>(
      this.collection, "g.V()");

  if(query.HasMoreResults)
  {
    foreach (dynamic item in await query.ExecuteNextAsync())
    {
      result.Add(item);
    }
  }

  return result;
}

GremlinManager.GetAllVertex()の呼び出しと実行結果は以下の通りです(リスト12)。

リスト12 Program.cs
GremlinManager manager = new GremlinManager();

Console.WriteLine("");
Console.WriteLine("-start- すべてのVertexをリストします");

var ret = await manager.GetAllVertex();
foreach (var vertex in ret)
{
  Console.WriteLine("---");
  Console.WriteLine("id: " + vertex.id);

  string label = vertex.label;
  if (label == "Book")
  {
    Console.WriteLine("title: " + vertex.properties.title[0].value);
    Console.WriteLine("author: " + vertex.properties.author[0].value);
  }
}

Console.WriteLine("-end-");
リスト12の実行結果
-start- すべてのVertexをリストします
---
id: daigo
---
id: tanaka
---
id: kido
---
id: sakai
---
id: 978-4101339115
title: きらきらひかる
author: 江國香織
---
id: 978-4101339153
title: 流しのしたの骨
author: 江國香織
---
id: 978-4041800089
title: キッチン
author: 吉本ばなな
---
id: 978-4903620510
title: 月に吠える
author: 萩原朔太郎
---
id: 978-4087713664
title: 抱擁、あるいはライスには塩を
author: 江國香織
-end-

(2) idでVertexを検索

特定のidを持つVertexを検索します。
Gremlin構文は以下の通りです。

g.V('【id】')

GremlinManagerクラスにGetVertexById()メソッドを追加します(リスト13)。

リスト13 GremlinManager.cs

public async Task<dynamic> GetVertexById(string id)
{
  dynamic result = null;

  string gr = string.Format("g.V('{0}')", id);

  var query =
    this.client.CreateGremlinQuery<dynamic>(
      this.collection, gr);

  if (query.HasMoreResults)
  {
    foreach (dynamic item in await query.ExecuteNextAsync())
    {
      result = item;
    }
  }

  return result;
}

GremlinManager.GetVertexById()の呼び出し及び実行結果は以下の通りです(リスト14)。

リスト14 Program.cs

GremlinManager manager = new GremlinManager();


dynamic vertex = await this.manager.GetVertexById("978-4087713664");

Console.WriteLine("");
Console.WriteLine("-start- 978-4087713664のVertexを検索します");

Console.WriteLine("id: " + vertex.id);
Console.WriteLine("title: " + vertex.properties.title[0].value);
Console.WriteLine("author: " + vertex.properties.author[0].value);

Console.WriteLine("-end-");

6.4.4 Edgeの検索

すべてのVertex を一覧してみます。

Gremlin構文は以下となります。

g.E()

GremlinManagerクラスにGetAllEdge()メソッドを追加します(リスト15)。

リスト15 GremlinManager.cs

public async Task<List<dynamic>> GetAllEdge()
{
  List<dynamic> result = new List<dynamic>();

  var query =
    this.client.CreateGremlinQuery<dynamic>(
      this.collection, "g.E()");

  if (query.HasMoreResults)
  {
    foreach (dynamic item in await query.ExecuteNextAsync())
    {
      result.Add(item);
    }
  }

  return result;
}

上記コードから分かるように、Vertex一覧と同じ要領です。

6.4.5 Vertex~Edge~Vertexを辿る(1)

次に少しグラフデータベースらいしい検索を行ってみます。

  • daigo が購入した Book を同様に購入した Customer を取得します
    (つまり嗜好の同じCustomerを抽出します)

GremlinManager.GetSameOrderCustomers()メソッドを追加します(リスト16)。

リスト16 GremlinManager.cs

public async Task<List<dynamic>> GetSameOrderCustomers(string id)
{
  List<dynamic> result = new List<dynamic>();

  string gr = string.Format(
    "g.V('{0}').as('self').outE('order').inV().inE().outV().where(neq('self'))",
    id);
  // 上記の省略形は以下です。
  //string gr = string.Format(
  //  "g.V('{0}').as('self').out('order').in().where(neq('self'))",
  //  id);

  var query =
    this.client.CreateGremlinQuery<dynamic>(
      this.collection, gr);

  if(query.HasMoreResults)
  {
    foreach (dynamic item in await query.ExecuteNextAsync())
    {
      result.Add(item);
    }
  }

  return result;
}

GetSameOrderCustomers()で実行されるGremlin構文は以下になります。

g.V('daigo').as('self').outE('order').inV().inE().outV().where(neq('self'))

ごちゃごちゃしていますが、先頭から順番に「.(ドット)」区切りで見ていくと理解することができます。
また、以下の説明の下に、処理に該当する番号を振った図を示します。

  • 1 “g.V(‘daigo’)”
    Vertexの中から id=daigo のものを取得します。

  • 2 “as(‘self’)”
    as()はエイリアスになります。
    selfには任意の文字列を指定することができます。 以降の構文中で self をして Vertex(“daigo”) を指すことができます。

  • 3 “outE(‘order’)”
    対象のVertexから出力しているEdge、さらにラベルが'order'のものを取得します。
    ここでは1つの order Edge が該当します。

  • 4 “inV()”
    対象のEdgeから入力しているVertexを取得します。
    ここでは1つの Book Vertex が該当します。

  • 5 “inE()”
    対象のVertexに入力しているEdgeを取得します。
    ここでは3つの Order Edge が該当します。

  • 6 “outV()”
    対象のEdgeに出力しているVertexを取得します。
    ここでは3つの Customer Vertex が該当します。

  • 7 “where(neq(‘self’))”
    whereはSQLなどと同じく抽出条件を指定します。
    neq()は not equal の意味を持ちます。
    selfは as() により指定した V(‘daigo’) を指します。
    つまり、このGremlin構文においては「自分自身は除外する」という意味を持ちます。 最終的に2つのCustomer Vertexが該当します。

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

GremlinManager.GetSameOrderCustomers()の呼び出しと実行結果は以下の通りです(リスト17)。

リスト17 Program.cs

Console.WriteLine("");
Console.WriteLine("-start- daigoと同じ書籍を購入したCustomerをリストします");

var ret = await manager.GetSameOrderCustomers("daigo");
foreach (var vertex in ret)
{
  Console.WriteLine(vertex.id);
}

Console.WriteLine("-end-");
リスト17の実行結果

-start- daigoと同じ書籍を購入したCustomerをリストします
tanaka
kido
-end-

6.4.6 Vertex~Edge~Vertexを辿る(2)

最後に以下のクエリーを行います。

  • daigo が購入した書籍と同じものを購入したCustomerが購入した別の本、つまりdaigoへのリコメンド書籍を取得します。

リスト18がGremlinManager.GetRecomendBooks()の実装になります。

リスト 18 GremlinManager.cs

public async Task<List<dynamic>> GetRecomendBooks(string id)
{
  List<dynamic> result = new List<dynamic>();

  string gr = string.Format(
    "g.V('{0}').as('self').outE('order').inV().as('sourceBook').inE().outV().where(neq('self')).outE('order').inV().where(neq('sourceBook'))",
    id);

  var query =
    this.client.CreateGremlinQuery<dynamic>(
      this.collection, gr);

  if (query.HasMoreResults)
  {
    foreach (dynamic item in await query.ExecuteNextAsync())
    {
      result.Add(item);
    }
  }

  return result;
}

GremlinManager.GetRecomendBooks()の呼び出しはリスト19になります。

リスト19 Program.cs

GremlinManager manager = new GremlinManager();

Console.WriteLine("");
Console.WriteLine("-start- daigoにおすすめの書籍をリストします");

var ret = await manager.GetRecomendBooks("daigo");
foreach (var vertex in ret)
{
  Console.WriteLine("id: " + vertex.id);
  Console.WriteLine("title: " + vertex.properties.title[0].value);
  Console.WriteLine("author: " + vertex.properties.author[0].value);
}

Console.WriteLine("-end-");

6.5 まとめ

Cosmos DBのGremlin(グラフ)についての説明を行いました。
Gremlin言語については、ほんの一部の機能のみの説明にとどめましたが、SQLと同様に多数の構文が存在するため、以下の公式ドキュメントから確認していただくのが良いと思います。

TinkerPop3 Documentation

また、本説明ではシングルパーティションコレクションを作成・使用しましたが、パーティショニングに関する考え方はDocumentDB / MongoDBの時と同様です。

では、次回は「Table API(データモデル)」の説明になります。

6.6 資料

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

github.com

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

Azure SQL Databaseで、Elastic PoolにGEOレプリケーションする時にハマった話

はじめに

Azure SQL Databaseは単独リージョンへの展開のみで 99.99% の可用性をSLAで保証しています。(SQL Database の SLA

とはいえ「2017年3月 のAzure障害祭り」のようなことが発生することも事実です。
(私個人としては、”Azure(やクラウド)批判派”ではなく”Azure(クラウド)推進派”であり、障害は起こる前提を想定すべき、との立場です)

単独で 99.99% のアベイラビリティに加えて「GEOレプリケーション」を組んでおく事が、さらなる安心、さらなるアベイラビリティの向上につながります。

という事で(仕事上のこともあり)、Azure SQL DatabaseのGEOレプリケーションについて調べる中で、少しハマった事がありましたので、ここにメモしておきます。

行おうとした事は以下です。

  • 複数のデータベースを東日本リージョンに作成する
  • 複数のデータベースは、1つのエラスティック データベース プールを利用する
  • 複数のデータベースは、それぞれ西日本リージョンにGEOレプリケーションする
  • 西日本リージョンのGEOレプリケーション先は、マスターより eDTU値 を落としたエラスティック データベース プールとする

1. マスターのSQL Databaseを作成

Azureポータルを表示します。

1.1. 1つ目のSQL Databaseを作成

「新規作成 → SQL Databaseを検索」します。

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

SQL Database」を作成します。

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

作成するSQL Databaseは以下の設定とします。

データベース名: RdExampleSQL1
リソースグループ: RdExampleSQL
サーバー: サーバー名 rdsqlsrv1 / 場所 東日本

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

SQLエラスティックプールを使用。
エラスティックプールデータベース: 名前 RdSqlPool1 / 価格レベル Standard Pool / プールの構成 200 eDTU

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

「作成」ボタンをクリックしたら、デプロイが完了するまでしばらく待ちます・・・

1.2. 2つ目のSQL Databaseを作成

先程と同様の手順にてSQL Databaseの作成を行います。
構成内容は以下とします。

データベース名: RdExampleSQL2
リソースグループ: RdExampleSQL
SQLエラスティックプール使用: あり
エラスティックプールデータベースプール: RdSqlPool1(先程作成したRdExampleSQL1と共通のプール)

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

以上で東日本リージョンに「RdSqlPool1」というエラスティック データベース プール上に、2つのデータベース「RdExampleSQL1」と「RdExampleSQL2」が作成されました。

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

1.3. [NG!!]Geoレプリケーションを構成

ここからが、私がちょっとハマった点です。。。
「RdExampleSQL1」SQLデータベースを西日本リージョンにGEOレプリケーションさせようとしました。

「RdExampleSQL1」を選択します。

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

「geoレプリケーション」を選択します。

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

レプリケーション先として「西日本」リージョンを選択します。

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

すると、「エラスティック データベース プール」が選択できません。
「対象サーバー」を構成しても「エラスティック データベース プール」はロックされた状態のままです。

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

ここで一度、私は「GEOレプリケーションにはエラスティック データベース プールが使えないのではないか?」と考えてしまいました・・・
が、事実はそうではなく以下の手順を行うことで可能でした。
(ちなみに、以下の方法は帰宅途中の電車の中で突然「もしかして!?」と思いつきました。人間の突然の思いつき、は不思議・・・^^;)

2. レプリケーション先のエラスティック データベース プールを作成

正しい手順としては、「1.2. 2つ目のSQL Databaseを作成」の続きになります。

「新規作成 → SQL エラスティック データベース プール」を検索・作成します。

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

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

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

以下の画面で、「サーバー」をクリックし、「新しいサーバーの作成」を選択します。
サーバー構成は以下とします。

サーバー: rdsqlsrv2
場所: 西日本

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

「プールの構成」をクリックします。eDTU を 50 に設定します(マスターの eDTU 200 より低い値)。

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

「作成」ボタンをクリックしたら、デプロイが完了するまでしばらく待ちます・・・

以下が、西日本リージョンに「エラスティック データベース プール(RdSqlPool2)/ サーバー(rdsqlsrv2)」が作成された画面です。

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

3. Geoレプリケーションを構成

では RdExampeSQL1(東日本リージョン) を西日本リージョンにGEOレプリケーションしましょう。

RdExampleSQL1(SQLデータベース)を選択し、「geoレプリケーション」メニューをクリックします。

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

レプリケーション先として「西日本」リージョンを選択します。

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

以下の画面で、「対象サーバー」をクリックすると、先程作成した「rdsqlsrv2」が選択することができます。
「rdsqlsrv2」を選択します。

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

「エラスティック データベース プール」をクリックします。

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

「RdSqlPool2」を選択します。

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

「OK」ボタンをクリックすると、デプロイが開始されます。

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

RdExampeSQL2 についても同様の geoレプリケーション 設定を行います。

4. 作成結果

以上の操作により、以下のようなAzure SQL Database構成が完成しました。

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

  • 東日本リージョンのエラスティック データベース プールは 200eDTU

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

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

  • 東日本リージョンの2つのデータベース「RdExampleSQL1 / RdExampleSQL2」は西日本リージョンの 50 eDTU 環境のレプリケーションされている
    f:id:daigo-knowlbo:20170616011717p:plain
    f:id:daigo-knowlbo:20170616011800p:plain

まとめ

ということで、Azure SQL Databaseにおいて、無事、2リージョン間で「エラスティック データベース プールによるGeoレプリケーション」が完成しました。
エラスティック データベース プールを使うか、データベース個別にDTUを確保するか等はシステム規模や要件、コストにより要検討事項ですが、決した安くはないクラウドにおいて、適切なプランや構成を選択していきたいですね。

また、今回「Amazon RDS」も並行して調べてみたのですが、RDSの場合はMulti-AZ構成にしても「SQL Serverを使用した場合には、アベイラビリティに関するSLAが付かない」という点にちょっとびっくりしました。「MySQL, MariaDB, Oracle or PostgreSQL」の場合には 99.95% のアベイラビリティSLAされます。

という個人的にハマったことを含めての投稿でした。

※週末にはまたCosmos DB入門の続きを書きますー、ではー。

Azure Cosmos DB入門(5)

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

ryuichi111std.hatenablog.com

5 Cosmos DBプログラミング ~ MongoDB編

前回・前々回のDocumentDB編に続き、今回は「MongoDB編」となります。

5.1 はじめに

MongoDBは、DocumentDBと同じく、保持するデータモデル形式が「ドキュメントデータモデル」となります。
つまり JSON形式 のドキュメントデータを保持・検索・更新するNoSQLデータベースとなります。
そもそも、MongoDBは 2009年にリリースされました。
当時、先発NoSQLプロダクトであった「Redis」や「Cassandra」よりも高機能なドキュメント指向データベースとして注目を集めました。(Apache CouchDBなんかも2005年からあるドキュメント指向データベースです・・・私はこちらには明るくないのですが・・・)

Cosmos DBでAPI/データモデルとしてMongoDBを選択するメリットには、以下のようなポイントがあげられます。

* 既存の MongoDBベースシステム のCosmos DBへの移行が容易
既にMongoDBベースのシステムがあり、これをクラウド化する場合、データアクセス部に関して既存資産を生かしたままCosmos DBへの移行が行えます。
※厳密には、Cosmos DB(MongoDB API/データモデル)とオンプレMongoDBとの互換性レベルについては、私も未確認なので要検証の部分があると思っています。
例えば、Cosmos DBでは 単一要素に対するHashedシャーディング のみをサポートしています。

* MongoDBの知識を有するエンジニアによるCosmos DBシステムの構築が容易
Cosmos DBの他のAPIモデルである DocumentDB や Tableに比べて、MongoDBスキルを有するエンジニアの方が世の中には多いでしょう。
その為、エンジニアの学習コストを抑えたうえでCosmos DBベースのシステムの構築が可能になります。

* Azureクラウド および オンプレ 両対応のシステムの構築が可能
API・データモデルに DocumentDB や Table を選択した場合、その動作環境はAzureクラウドに限定されてしまいます。
システムによってはクラウドおよびオンプレでサービス展開を行いたいケースもあるでしょう。そのようなケースでは、MongoDBモデルが有効になります。

5.1.1 Cosmos DB(MongoDBデータモデル)とMongoDB

マルチデータモデルである Cosmos DB のサポートする1つのデータモデルとしてMongoDBがサポートされています。
MongoDBは、DocumentDB と同じく「JSONフォーマットベースのドキュメント」をデータとして保持します(正確にはDocumentDBよりも以前からMongoDBはドキュメン指向NoSQLデータベースとして君臨していました)。
Cosmos DBによるMongoDB対応を図にすると以下のようなイメージになります。

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

Cosmos DBはMongoDBのプロトコルに準拠したI/Fを提供します。
その為、従来から存在するMongoDB用のクラスライブラリを用いた開発が行えますし、「mongoコマンド」や「Studio 3T for MongoDB(旧MongoChef)」から接続する事が可能です。

5.2 準備

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

Azureポータルを利用して「Cosmos DB(DocumentDB)」を作成します。

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

f:id:daigo-knowlbo:20170608232706p:plain f:id:daigo-knowlbo:20170608232709p:plain

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

「MongoDB.Driver」と、その依存関係である「MongoDB.Driver.Core」がインストールされます。
「MongoDB.Driver」は、「MongoDB, Inc.」により提供される「Official .NET driver for MongoDB.」です。
つまり、Azure Cosmos DB(MongoDB)専用のものではなく、Cosmos DBとは無関係な 一般的なMongoDB に接続するための公式ドライバーとなります。

最後にCosmos DB(MongoDB)に保存するドキュメントモデルクラスとして以下のRoomReservationInfo.csをプロジェクトに追加します。これは前回のDocumentDBの解説で使用したのと同じドキュメントモデルクラスになります。
1点だけ、「Idプロパティに対する JsonProperty属性 指定」に相違があります。
モデルクラスをJSON形式に変換した際のプロパティ名の変更マップの指定ですが、DocumentDB時には「id」としていました。今回のMongoDBでは「_id」としています。
データモデルの方言の違いで、DocumentDBでは「ドキュメント識別子=id」であるのに対し、MongoDBでは「ドキュメント識別子=_id」と決められているためです。パーティションキー(シャーディングキー)の指定がある場合は、id / _idに加えてパーティションキー値(シャーディングキー値)との複合キーが、コレクション内におけるドキュメント識別子となります。

// RoomReservationInfo.cs

using System;
using System.Collections.Generic;
using Newtonsoft.Json;

namespace CosmosMongoDBExample
{
  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; }
  }
}

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

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

今回のCosmos DB(MongoDB)においても、DocumentDBの場合と同様に「データベースアカウント → データベース → コレクション → ドキュメント(JSONデータ)」というデータモデル構造となります。
まず、データベースとコレクションを作成したいと思います。
作成方法はいくつかありますが、その中でも以下の3つの方法についてここでは取り上げることとします。

  • Azureポータルで作成
  • プログラムで作成
  • mongoコマンドで作成

5.3.1 Azureポータルで作成

Azureポータルで、作成した cosmosmongo111 を選択し、サイドバーから「データエクスプローラ」メニューを選択します。
続いて「New Collection」ボタンをクリックします。

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

「Add Collection」画面が表示されるので、必要事項を入力することでデータベースおよびコレクションを作成することができます。

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

作成されたデータベース・コレクションは以下の通りです。

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

この後の手順を進めるために、一旦、このGroupwareDBデータベースは削除しましょう。
「GroupwareDB」の右側にマウスカーソルを持っていくと「…」が表示され、これをクリックすると以下のようなポップアップメニューが表示されます。 「Delete Database」を選択します。

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

削除の確認のためにデータベースIDを入力し、「OK」ボタンをクリックします。

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

データベースの削除が完了しました。

5.3.2 プログラムで作成

本稿では、Cosmos DB(MongoDB)操作を管理するクラスとして MongoDbManagerクラス を用意することとします。
プロジェクトに MongoDbManagercs を追加します。

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

(1)接続情報の定義

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

リスト1 MongoDbManager.cs
private const string host = "【ホスト名】";
private const string userName = "【ユーザー名】";
private const string password = "【パスワード】";
private const string database = "GroupwareDB";
private const string collection = "RoomReservations";
private const int port = 10255;

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

【ホスト名】【ユーザー名】【パスワード】は各データベースアカウントごとに適切な値を設定します。
Azureポータルで「接続文字列」タブを選択することで確認することができます。

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

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

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

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

リスト2 MongoDbManager.cs
// 一般的に利用する名前空間
using System;
using System.Collections.Generic;
using System.Security.Authentication;
using System.Linq;
using System.Threading.Tasks;

// MongoDB関連の名前空間
using MongoDB.Driver;
using MongoDB.Driver.Core;

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

CosmosDB(MongoDB)に接続する為の接続クライアント「MongoDB.Driver.MongoClient」をMongoDbManagerクラスのフィールド変数「client」として定義します(リスト 3)。
また、clientはコンストラクタで初期化することとします。

リスト3 MongoDbManager.cs
public class MongoDbManager {
  private MongoClient client = null;
  
  public MongoDbManager()
  {
    // MongoClientの初期化
    var settings = new MongoClientSettings
    {
      Server = new MongoServerAddress(MongoDbManager.host, MongoDbManager.port),
      ServerSelectionTimeout = TimeSpan.FromSeconds(5)
    };
    settings.SslSettings = new SslSettings();
    settings.UseSsl = true;
    settings.SslSettings.EnabledSslProtocols = SslProtocols.Tls12;

    MongoIdentity identity = new MongoInternalIdentity(MongoDbManager.database, MongoDbManager.userName);
    MongoIdentityEvidence evidence = new PasswordEvidence(MongoDbManager.password);

    settings.Credentials = new List<MongoCredential>()
    {
      new MongoCredential("SCRAM-SHA-1", identity, evidence)
    };
    this.client = new MongoClient(settings);
  }
}

MongoClientSettingsオブジェクトにより接続の為の情報をいくらか設定する必要があります。
MongoClientSettingsのコンストラクタでは、接続先ホスト名とポート番号、タイムアウト時間(5秒)により初期化しています。
また、Azure Cosmos DBはSSL(TLS)による接続をサポートするため、MongoClientSettings.SslSettingsの設定も行っています。

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

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

リスト4 MongoDbManager.cs
public async Task<bool> CreateCollection()
{
  IMongoDatabase mongoDatabase = 
    this.client.GetDatabase(MongoDbManager.database);

  await mongoDatabase.CreateCollectionAsync(MongoDbManager.collection);

  return true;
}

MongoClient.GetDatabase()メソッド呼び出しにより「データベース」オブジェクト(IMongoDatabaseインターフェイス型)を取得することができます。未作成のデータベースでもGETすることができます。
続けて、取得したIMongoDatabaseオブジェクトのCreateCollectionAsync()を呼び出すことで、データベースおよびコレクションが作成されます(データベースもこのタイミングで同時に作成されます)。

以下が MongoDbManager.CreateCollection() 呼び出しコードです(リスト5)。

リスト5 Program.cs
MongoDbManager manager = new MongoDbManager();
bool result = manager.CreateCollection().Result;

上記コードによるデータベース・コレクションの作成を行った結果をAzureポータルで確認したものが以下です。
シングルパーティション構成、予約RUは1000での作成となります。

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

先程と同様、一旦 GroupwareDB データベースは削除しておきましょう。

5.3.3 mongoコマンドで作成

mongoコマンドを使用して、Cosmos DB(MongoDB)に接続することが可能です。
mongoコマンドは、データベース・コレクションの作成、ドキュメントの作成・更新・削除・抽出、また、シャーディング設定といった多くの操作を行うことが可能です。

mongoコマンド利用の準備

mongoコマンドはMongoDBをインストールすることで利用可能になります。
MongoDBは以下のURLからダウンロードすることができます。

MongoDB Download Center | MongoDB

以下のダウンロード画面では、ドロップダウンによる Version の選択があります。Windows10のようなクライアントOSでも「Windows Server 2008 R2 64-bit and later, with SSL support x64」を選択すればOKです。

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

ダウンロードしたセットアップEXEを起動し、インストーラの指示に従うのみでインストール可能です。
しかし、MongoDBサーバーは不要、Cosmos DB(MongoDB)との接続クライアントであるmongoコマンドのみを必要とする場合は、以下の画面に示すように Custom インストールモードにて「Client」のみをセットアップすることができます。

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

インストール完了後は「環境変数」の設定により、「C:\Program Files\MongoDB\Server\3.4\bin」にPathを通しておくと便利です。

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

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

環境変数設定後は、Windowsを再起動します。

mongoコマンドを利用

コマンドプロンプトを起動します。
以下のコマンドを実行し、Azure Cosmos DB(MongoDB)と接続します。
※【ホスト名】【ユーザーID】【パスワード】は、Azureポータル→cosmosmongo111→接続文字列から確認。

mongo --ssl --host 【ホスト名】 --port 10255 -u 【ユーザーID】 -p 【パスワード】

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

「use 【データベース名】」コマンドを実行します。
ここでは use GroupwareDB としました。

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

GroupwareDBデータベースは以下に、シャーディングを有効にしたコレクションを作成することにします。
シャーディングとはMongoDBの機能で「水平スケーリング」を行うための機能です。Cosmos DBでは「パーティショニング」として実装されています。
以下のコマンドを実行します。

db.runCommand( { shardCollection: "GroupwareDB.RoomReservations", key: { Room: "hashed" } } )

このコマンドを実行したタイミングで、データベースおよびシャーディング有効コレクションが作成されます。
シャーディングキー(=パーティションキー)は「Room」としました。対象コレクションに保存する予定の RoomReservationInfoクラスのRoomプロパティ を表します。

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

Azureポータルで確認した画面が以下になります。シャーディング(パーティショニング)が有効になったため、設定可能な最低RUが2500となっています。そして、実際に作成されたコレクションのRUは2600となります(なぜ2500ではなく2600なのかは不明・・・)。

f:id:daigo-knowlbo:20170609032024p:plain
f:id:daigo-knowlbo:20170609233527p:plain

5.4 ドキュメントの作成

MongoDbManagerクラスにドキュメントを作成するメソッド「CreateRoomReservationInfo()」を定義します(リスト 6)。

リスト6 MongoDbManager.cs
public async Task<bool> CreateRoomReservationInfo(RoomReservationInfo roomReservationInfo)
{
  var mongoDatabase = this.client.GetDatabase(MongoDbManager.database);
  var mongoCollection = mongoDatabase.GetCollection<RoomReservationInfo>(MongoDbManager.collection);
  await mongoCollection.InsertOneAsync(roomReservationInfo);

  return true;
}

MongoClientオブジェクトから IMongoDatabaseオブジェクト を取得します(MongoClient.GetDatabase()メソッド呼び出し)。
さらに IMongoCollectionオブジェクトを取得します(IMongoDatabase.GetCollection())。
IMongoDatabase.GetCollection()では扱うモデルクラス型を設定します。ここではRoomReservationInfo型としました。 IMongoCollection.InsertOneAsync()メソッドを呼び出すことで新規のドキュメントを作成することができます。

5.4.1 ドキュメントの一括投入 ~ Studio 3T for MongoDB(旧Mongo chef)

以降の検索の説明等では、もっと大量のドキュメントが登録されている状態が好ましいです。
その為に、ここでデータ量を増やしておきたいと思います。
ここでは「Studio 3T for MongoDB(旧Mongo chef) 」というツールを利用します。
SQL Serverでいうところの「SQL Server Management Studio」のようなツールです。
以下からダウンロード可能です。

studio3t.com

インストールを行い起動します。起動したら左上の「Connect」をクリックします。

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

表示された「Connection Manager」ウィンドウで「New Connection」をクリックします。

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

表示された「New Connection」ウィンドウに接続情報を入力します。
入力する情報は Azureポータル → 対象のCosmosDB → 接続文字列 で確認することができます。

「Server」タブの設定は以下の通りです。

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

「Authentication」タブの設定は以下の通りです。

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

SSL」タブの設定は以下の通りです。

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

接続した画面が以下になります。

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

左のツリーから RoomReservationsコレクションを選択し、マウス右ボタンクリックでポップアップメニューを開きます。
ポップアップメニューから「Import Data…」を選択します。

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

JSON形式を選択しNextボタンをクリックします。

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

「+」ボタンをクリックし、インポートソースとなるjsonファイル(exampledata.json)を選択します。

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

インポート内容の確認を行い、「Start Import」ボタンをクリックします。

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

jsonファイルからAzure Cosmos DB(MongoDB)にデータが投入されました。

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

5.5 ドキュメントの検索

RoomReservationsコレクションに登録されたドキュメントをいくつかのパターンで検索してみます。

5.5.1 Mongoクエリー言語による検索

Mongoクエリー言語によるクエリーを行います。
Mongoクエリー言語はJSON形式で記述が可能なMongoDB用クエリー言語です。

(1) Roomによる検索

パーティションキー(シャーディングキー)であるRoom(RoomReservationInfo.Room)による検索を実装します。
MongoDbManagerクラスにFindByRoom(stirng room)メソッドを追加します。

リスト7 MongoDbManager.cs
public async Task<List<RoomReservationInfo>> FindByRoom(string room)
{
  var mongoDatabase = this.client.GetDatabase(MongoDbManager.database);
  var mongoCollection = mongoDatabase.GetCollection<RoomReservationInfo>(MongoDbManager.collection);

  var find = await mongoCollection.FindAsync<RoomReservationInfo>("{ Room: \"" + room + "\"}");
  return find.ToList();
}

コレクションオブジェクトに対して、FindAsync()メソッドを呼び出します。
は取得するドキュメントクラスである RoomReservationInfo を指定します。
引数の文字列がMongoクエリー言語による抽出条件式です。FindByRoom()メソッド引数を使用して文字列連結していますが、展開すると例えば以下のようなJSON文字列になります。

{ Room: "第1会議室" }

「Room要素の値が第1会議室である」という条件式になります。
SQL風に表すと以下のようになります。

WHERE Room = '第1会議室'

MongoDbManager.FindByRoom()の呼び出し側の実装は以下の通りです(リスト8)。

リスト8 MongoDbManager.cs
MongoDbManager manager = new MongoDbManager();

var roomReservationInfos = manager.FindByRoom("第1会議室").Result;

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));
}

(2) Roomによる検索(その2)

作成したFindByRoom()メソッドは以下のように書き換えることができます(リスト9)。
動作上の意味はイコールになります。

リスト9 MongoDbManager.cs
public async Task<List<RoomReservationInfo>> FindByRoomEx(string room)
{
  var mongoDatabase = this.client.GetDatabase(MongoDbManager.database);
  var mongoCollection = mongoDatabase.GetCollection<RoomReservationInfo>(MongoDbManager.collection);

  FilterDefinition<RoomReservationInfo> query = 
      Builders<RoomReservationInfo>.Filter.Eq(r => r.Room, room);
  var find = await mongoCollection.FindAsync<RoomReservationInfo>(query);
  return find.ToList();
}

リスト8とリスト9の違いは、FindAsync()メソッドへの引数にあります。
リスト8では「{ Room: “第1会議室” }」のようなJSON文字列条件式を渡していました。
一方、リスト9では「FilterDefinition型」を引数として渡しています。
FindAsync()メソッドの定義を見ると、本来 JSON文字列(stirng型) を引数として受け取る実装はありません。
なぜJSON文字列(string)を引数として渡すことができているのか?それはC#のimplicitキーワード機能に起因します。

implicit (C# リファレンス) | Microsoft Docs

「MongoDB.Driver.FilterDefinitionクラス」の実装は、以下のgithubで確認することができます。

mongo-csharp-driver/FilterDefinition.cs at master · mongodb/mongo-csharp-driver · GitHub

その中で、以下のリスト10の実装が行われています。
「implicit operator FilterDefinition(string json)」、つまり、string json型からFilterDefinition型への暗黙的変換を行うオペレータを定義しています。
リスト8が実行される際、FindAsync()の「引数string」は、FilterDefinitionのimplicit operatorにより、FilterDefinitionオブジェクトに型変換されています。

リスト10 FilterDefinition.cs
// Mongo C# Driver実装より引用

public abstract class FilterDefinition<TDocument>
{
  ... 省略
  
  /// <summary>
  /// Performs an implicit conversion from <see cref="System.String"/> to <see cref="FilterDefinition{TDocument}"/>.
  /// </summary>
  /// <param name="json">The JSON string.</param>
  /// <returns>
  /// The result of the conversion.
  /// </returns>
  public static implicit operator FilterDefinition<TDocument>(string json)
  {
    if (json == null)
    {
      return null;
    }

    return new JsonFilterDefinition<TDocument>(json);
  }
  
  ... 省略
}

(3) _id + パーティションキーによる検索

次に _id と パーティションキーRoom による検索を行います。
今回のサンプルコレクションでは、パーティショニング(シャーディング)設定を行っているので、「_id + パーティションキーRoom」がドキュメントをユニークに識別するキーになります(リスト11)。

リスト11 MongodbManager.cs
// Roomと_idから、単一のRoomReservationInfoを取得します。
public async Task<RoomReservationInfo> FindByRoomAndId(string room, string id)
{
  var mongoDatabase = this.client.GetDatabase(MongoDbManager.database);
  var mongoCollection = mongoDatabase.GetCollection<RoomReservationInfo>(MongoDbManager.collection);

  var find = await mongoCollection.FindAsync<RoomReservationInfo>(
    "{ Room: \"" + room + "\", _id: \"" + id + "\"}");

  return find.FirstOrDefault();
}

上記リスト11で実行されるMongoクエリーJSONは、以下のような形式になります。

{ Room: "第1会議室", _id: "000000001" }

(4) 複合条件による検索

次に「Room=【Room】 AND AssignMembers.UserID = 【UserID】」という2つの値のAND条件の実装を行います(リスト12)。

リスト12 MongoDbManager.cs
public async Task<List<RoomReservationInfo>> FindByRoomAndAssignMember(string room, string assignMemberId)
{
  var mongoDatabase = this.client.GetDatabase(MongoDbManager.database);
  var mongoCollection = mongoDatabase.GetCollection<RoomReservationInfo>(MongoDbManager.collection);

  var find = await mongoCollection.FindAsync<RoomReservationInfo>(
    "{ Room: \"" + room + "\" , AssignMembers: { $elemMatch: { UserId: \"" + assignMemberId + "\"}}}");
  return find.ToList();
}

5.5.2 LINQによる検索

JSONによるMongoクエリー言語以外にLINQクエリーを利用することができます。
リスト8をLINQベースで書き直したFindByRoomLinq()メソッドの実装はリスト13です。

リスト13 MongoDbManager.cs
public async Task<List<RoomReservationInfo>> FindByRoomLinq(string room)
{
  var mongoDatabase = this.client.GetDatabase(MongoDbManager.database);
  var mongoCollection = mongoDatabase.GetCollection<RoomReservationInfo>(MongoDbManager.collection);

  var find = await mongoCollection.FindAsync<RoomReservationInfo>(r => r.Room == room);

  return find.ToList();
}

5.6 ドキュメントの更新

ドキュメントを更新する処理を実装します(リスト14)。
RoomReservationInfoの Titleプロパティ のみを変更する処理とします。

リスト14 MongoDbManager.cs
public async Task<bool> UpdateTitle(RoomReservationInfo roomReservationInfo)
{
  var mongoDatabase = this.client.GetDatabase(MongoDbManager.database);
  var mongoCollection = mongoDatabase.GetCollection<RoomReservationInfo>(MongoDbManager.collection);

  // パーティショニング(シャーディング)構成なので Room + _id でユニークなキー
  var result = await mongoCollection.UpdateOneAsync<RoomReservationInfo>(
    r => r.Id == roomReservationInfo.Id && r.Room == roomReservationInfo.Room, 
    new UpdateDefinitionBuilder<RoomReservationInfo>().Set(i => i.Title, roomReservationInfo.Title));

  return true;
}

ドキュメントの更新には、UpdateOneAsync()メソッドを呼び出します。

第1引数で、更新対象のドキュメントを特定します。
第2引数で、更新個所を設定します。今回は Title のみの更新になります。

リスト14のドキュメント更新実装を呼び出す処理は以下の通りです(リスト15)。

リスト15 
MongoDbManager manager = new MongoDbManager();

// 1件取得
var roomReservationInfo = manager.FindByRoomAndId("第1会議室", "0000000000").Result;

// タイトルプロパティを変更
roomReservationInfo.Title = "タイトル変更!!!!";

// 更新処理呼び出し
var result = manager.UpdateTitle(roomReservationInfo).Result;

5.7 ドキュメントの削除

ドキュメントを削除する処理を実装します(リスト16)。

リスト16 MongoDbManager.cs
public async Task<bool> Delete(RoomReservationInfo roomReservationInfo)
{
  var mongoDatabase = this.client.GetDatabase(MongoDbManager.database);
  var mongoCollection = mongoDatabase.GetCollection<RoomReservationInfo>(MongoDbManager.collection);

  var result = await mongoCollection.DeleteOneAsync<RoomReservationInfo>(
    r => r.Id == roomReservationInfo.Id && r.Room == roomReservationInfo.Room);

  return true;
}

DeleteOneAsync()を呼び出します。
引数でドキュメントをユニークに識別するための「Room(パーティションキー)」+「_id」を設定しています。

リスト16のドキュメント削除実装を呼び出す処理は以下の通りです(リスト17)。

リスト17
MongoDbManager manager = new MongoDbManager();

var roomReservationInfo = manager.FindByRoomAndId("第1会議室", "00001").Result;

var result = manager.Delete(roomReservationInfo).Result;

5.8 資料

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

github.com

5.9 つづく・・・

次回はCosmos DBによる Graph DB、Gremlinについての説明になります。ではー。

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

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)へ >>