Azure Cosmos DB入門(8)

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

ryuichi111std.hatenablog.com

8 Cosmos DBをもっと知りたい

これまでの「Azure Cosmos DB入門(1)~(7)」では、Azure Cosmos DBの概要から各データモデル(APIモデル)毎のプログラミング手法について説明してきました。
今回は「Azure Cosmos DB入門」の最終回として、Cosmos DBにおけるいくつかのトピックにスポットを当てて、少しだけ技術的に踏み込んでみたいと思います。

8.1 一貫性レベル(Consistency Level)

データベースにおいては「データの一貫性」は非常に重要な要素です。
書き込んだデータは、当然の如く「書き込んだ値」にならなければなりません。
しかし、話はそれほど単純ではなく、このあたりの考え方には「CAP定理」というものが関係してきます。

CAP定理

CAP定理とは・・・その頭文字である「Consistency(一貫性 )・Availability(可用性)・Partition-tolerance(分断耐性 )」の3つの要素は同時に保証することは出来ない、という定理です。
C / A / Pのうち2つは保証することができるが、3つを同時に保証することはできないというものです。
一般的に、RDBにおいてはCとAを保証する設計となっています。分散型NoSQLデータベースにおいては、AとPを保証する設計となっています。
つまり、分散型NoSQLデータベースシステムである Cosmos DB も AとP の保証を優先しています。
ただし、2者を取り1者を完全に捨てるわけではなく、バランスとして2つを優先し1つの優先度を落とす、というのが実際のところの落としどころとなります。
CAP定理と、それに対する RDB / 分散型NoSQL のアプローチの考え方については、ちょっとした1冊の本としても成り立つくらいの内容なので、ここでは省略させていただきます。
ググるとたくさん情報が出てくるので、そちらをご参照ください。

多くの分散型NoSQLのアプローチ

多くの分散型NoSQLデータベースでは「Eventual」および「Strong」という一貫性レベルを提供することが多いです。
この二者択一に限らず、いくつかの細かな一貫性レベルの指定が可能なデータベースは存在しますが、分散型NoSQLデータベースにおける代表的な一貫性レベルは、この2つになります。

Eventualとは「結果整合性」の一貫性レベルを表します。更新されたデータは、最終的に一貫した値に収束するという考え方です。
下図のように、更新されたデータは一定の時間を経ると収束し、一貫性を保ちます。逆に言うと、収束するまでの間は、データは最新のデータを読み取れることもあれば、少し古いデータが読み取られることもあります。

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

一方 Strongでは、必ず最新のデータを読み取ることができます。

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

処理としては必ず最新のデータが読み取れた方が、シンプルであり、その方がうれしいでしょう。ただし、分散型NoSQLデータベースにおいては、Strong一貫性レベルによる読み取りは負荷の高い処理になります。RDBとは異なるアプローチにより、スケーラビリティ・アベイラビリティ・ローレイテンシーを目指す分散型NoSQLにおいては Strong は良い選択肢ではありません。
SNSのタイムライン表示などでは結果整合性を採用しても大きな害は発生しません。しかし、銀行システムの預金口座の表示などで、リロードしたタイミングで通帳の残高状況が変わってしまうのは大きな問題があります。
それぞれの業務要件を満たすために、NoSQLデータベース機能による一貫性の管理、および、アプリケーションロジックによる業務要件の担保、といった設計上の考慮が必要になるのが従来のRDBとCosmos DBのような分散型NoSQLの違いでもあります。

※ Cosmos DBにおいてStrong一貫性レベルを使用した場合、クエリー レイテンシーSLA保証されている為、SLA範囲を超える速度の劣化ではなく 消費RUの増加 という影響が現れます。
※ 一貫性という言葉に関してAzureポータルでは「整合性」と記述している箇所があります。これ以降、「一貫性」「整合性」の2つの言葉は同意とします。

8.1.1 Cosmos DBの提供する一貫性レベル(Consistency Level)

Cosmos DBにおいては 5つ の一貫性レベルが提供されます。
強い一貫性レベルから順に記述したのが、以下のリストになります。

  • Strong
  • Bounded Staleness
  • Session
  • Consistent Prefix
  • Eventual

(1) Strong(強固)

Linearizabile(線形化可能性)を保証する最も強い一貫性レベルです。
あるプロセスにより書き込みが行われると、別のどのプロセスから読み取っても最新のデータを読み取ることができます。
消費RUは高くなります。
また、Strong一貫性レベルでは、グローバルレプリケーションを行うことが出来ません(単一リージョンによる運用に限定されます)。

(2) Bounded Staleness(有界整合性制約)

Bounded Stalenessは K個のバージョンの書き込み もしくは t時間の間隔 だけ読み取りが遅れる可能性があります(つまり古いデータを読み取る可能性があります)。
Azureポータル上での設定画面は以下になります(データベースアカウント → 既定の整合性)。

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

最大ラグ操作(K個)および最大ラグ時間(t間隔)の設定可能範囲はグローバルレプリケーションの設定有無により異なります。

グローバルレプリケーション 最大ラグ操作数(K) 最大ラグ時間(t)
あり(レプリカあり) 100,000~1,000,000 5分~1日
なし(レプリカなし) 10~1,000,000 5秒~1日

やはりグローバルレプリケーションの有無によりその設定可能数値範囲に大きな相違があることが分かります。
また、消費RUは高くなります(Strongと同等)。

(3) Session(セッション)

読み取るデータは最新である保証はありません。ただし、最終的に書き込まれたデータは反映され一貫性と保った状態に収束します。
一貫性レベルとしては弱いですが、この後説明する「Consistent Prefix」「Eventual」と異なり、クライアント セッションにフォーカスした一貫性レベルとなります。
セッションにおいて monotonic reads / monotonic writes / read your own writes (RYW) が保証されています。
* monotonic reads
当該セッションから読み取れるデータは、読み取る毎にさらに古いデータにはならない。
* monotonic writes
当該セッションからの同一データへの書き込みは、書き込み順序が保証される。
* read your own writes (RYW)
当該セッションからのデータ書き込みは、(当該セッションからの)読み取りに反映される(書き込んだデータを読み取れる。古いデータが読み込まれることはない。)。

また、消費RUはStrong / Bounded Stalenessより低く、Eventualよりも高くなります。

(4) Consistent Prefix(一貫性のあるプレフィックス)

読み取るデータは最新である保証はありません。ただし、最終的に書き込まれたデータは反映され一貫性と保った状態に収束します。
レプリカに反映される書き込み順序は保証されます。
A→B→Cというデータ更新が行われた場合、クライアントから見ると、A、A→B、A→B→Cと書き込みが行われた処理データは読み込まれますが、A→CとかB→A→Cと順序が入れ替わって書き込まれる様子が見えることはありません。

(5) Eventual(最終的)

最も弱い一貫性レベルです。
読み取るデータは最新である保証はありません。ただし、最終的に書き込まれたデータは反映され一貫性を保った状態に収束します。
データを繰り返し読み取る際、1度目に取得したデータより更に古い世代のデータが読み取られることもあります。
読み取りと書き込みの待機時間は最も早くなります。
読み取り操作時の消費RUは最も低くなります。

8.1.2 既定の一貫性レベル

Cosmos DBでは「データベースアカウント」に対して、既定の一貫性レベルを設定することができます。
Azureポータル上で設定することができ、「データベースアカウント → 既定の整合性」メニューで変更することができます。

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

初期状態では、既定値は「セッション」になっています。

読み取り操作を行う場合、明示的な指定を行わない限り、データベースアカウントに対する既定の一貫性レベルが適用されます。
また、「読み取り要求を行う毎」に個別に一貫性レベルを明示的に指定することができます。
以下が例となります(リスト1)。

リスト1 一貫性レベルの明示的な指定(Session一貫性レベルで読み取る例)

DocumentClient client = 
  new DocumentClient(
    new Uri("https://[your account].documents.azure.com:443/"),
    "[your key]",
    new ConnectionPolicy() { // 実運用時はDirect/Tcp推奨
      ConnectionMode = ConnectionMode.Gateway,
      ConnectionProtocol = Protocol.Https},
    ConsistencyLevel.Session);  // ← 一貫性レベルを明示的に指定

// クエリー実行
var query =
    client.CreateDocumentQuery<RoomReservationInfo>(
        UriFactory.CreateDocumentCollectionUri("GroupwareDB", "ReservationCollections")).
        Where(r => r.Room == "大会議室");
var result = query.ToList();

リスト1実行時に発行されたHTTP RequestをFiddlerで確認した結果は以下です。
HTTP Headerに「x-ms-consistency-level: Session」が追加されています。

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: Sun, 09 Jul 2017 03:16:15 GMT
authorization: type%3dmaster%26ver%3d1.0%26sig%3dS7DKHRXurZXz6v6VqkMU8QceYxyDHoWQjAIOOn2N9ao%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ブル\") "}

操作毎の明示的な一貫性レベル指定に関する注意事項として、データベースアカウントに対して設定した既定の一貫性レベルより高いレベルの一貫性レベルを指定することはできません。

8.2 RU(Request Unit)詳細

「Azure Cosmos DB入門(2)」においてもRU(Request Unit)の概要について触れました。
それを踏まえて、また+αの概念も含めて、RUについてのポイントをまとめると、以下のようになります。

  • RUはCosmos DBにおけるクエリーや書き込みなどの「処理の計算量を表す抽象単位」である
    (1RU = 一貫性レベルSessionにおいてキー指定で1KBのデータを1件取得する操作に要する処理量)
  • クエリー処理を行うと、その複雑度・データ量等により消費されるRUが変動する
    (複雑なクエリーほど多くのRUを消費する)
  • RUはコレクションに対して割り当てる
  • 1秒あたり利用可能なRUをコレクションに対して割り当てる
    (Storage Capacity:Fixed(10GB)設定時=400~10,000RU/s。Unlimited設定時=2,500~100,000RU/s)
  • RU/second を超える処理量が一時的に必要になった時のために「RU/m(RU/分)」の設定が可能
  • RUはCosmos DBにおける課金対象の大部分を占める

8.2.1 RUの設定方法

コレクションに割り当てるRU/sの値は、Azureポータルおよびプログラムから行うことができます。
「2.4.4 予約RUの設定方法」ですでに説明したのでそちらを参照してください。

Azure Cosmos DB入門(2) - ryuichi111stdの技術日記

8.2.2 消費RUの確認方法

クエリーや更新の結果消費したRUの量の確認方法は、「2.4.5 消費RUの確認方法」ですでに説明したのでそちらを参照してください。

Azure Cosmos DB入門(2) - ryuichi111stdの技術日記

8.2.3 RU/s超過時のレスポンスコード429

RU/sは事前予約制です。
Cosmos DBは使用した分を精算する方式ではなく、事前にどれだけの処理能力(キャパシティ)を用意するかを RU/s の値として設定します。
RU/sは消費しても、しなくても利用料金として精算請求が行われます。
コレクションの作成と同時にRUの割り当てを行います。つまりコレクションを作成した時点で、格納するデータが空であっても、設定したRU分の課金が開始されます。

運用コストを考慮すると「必要最低限の RU/s値 をコレクションに対して割り当てたい」ということになります。
しかし、クライアントからのトラフィックに対して RU/s値 が十分でなかった、つまり足りなかった場合、レスポンスコード429というエラーが発生します。

レスポンスコード429の確認

では実際に レスポンスコード429 の発生を確認してみます。
以下のような、クエリーをforループで10回繰り返す処理を実行してみます(リスト2)。
処理対象のコレクションは、最小構成のRU/s=400としています。

リスト2 負荷をかけて400RU/sを超過させる

DocumentClient client = 
  new DocumentClient(
    new Uri("https://[your account].documents.azure.com:443/"),
    "[your key]",
    new ConnectionPolicy() {
      ConnectionMode = ConnectionMode.Gateway,
      ConnectionProtocol = Protocol.Https});

// クエリーを10回繰り返す
for(int i = 0; i < 10; i++)  {
  var query =
    this.client.CreateDocumentQuery<RoomReservationInfo>(
      UriFactory.CreateDocumentCollectionUri("GroupwareDB", "RoomReservations"))
      .Where(r => r.Room == "スタンディングテーブル");
  var result = query.ToList();
}

リスト2の実行結果のHTTPS通信をFiddlerで監視した結果は以下の画面です。

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

1度 429 が発生していることを確認できます。

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

レスポンスボディのJSONには、確かに「Requesr rate is large」とのエラーメッセージを確認することができます。
また、レスポンスヘッダに「x-ms-retry-after-ms: 488」という項目が確認できます。
これは 488ms 待ってから再度リクエストを行ってください、という意味を持ちます。
1秒毎にRUのキャパシティが復活するために、指定のリトライ待機時間を取った上で再度のリトライを行うことを促しています。
forループで10回のクエリーを実行しました。そして1回の429(RU超過)エラーが発生しました。その上で、200で成功したクエリー(~/RoomReservations/docs)は10回確認できます。
つまり、429エラーが発生した際は、ライブラリ側でリトライ処理が行われていることが分かります。
仮に、REST APIを自ら実行する方式でCosmos DBを操作した場合には、429の発生の認識およびリトライを自前で実装する必要があります。

8.2.4 リトライ動作の指定

ライブラリが行うリトライ処理の動作については、ユーザープログラム側で制御することができます。
DocumentClientクラスのコンストラクタ引数「ConnectionPolicyクラス」を利用します。
「ConnectionPolicy.RetryOptionsプロパティ」によってリトライ設定を制御することができ、これは「Microsoft.Azure.Documents.Client.RetryOptions」型となります。
以下の2つがRetryOptionsクラスの主要プロパティです。

  • RetryOptions.MaxRetryAttemptsOnThrottledRequestsプロパティ
    RU超過(429)発生に対して、何回までリトライを繰り返すかを設定します。設定したリトライ回数を超えると例外が発生します。

  • RetryOptions.MaxRetryWaitTimeInSecondsプロパティ
    最初の要求からの累積待ち時間の上限を設定します(単位は秒)。この上限値を超える待ち時間が発生すると例外が発生します。

以下がその利用例です(リスト3)。

リスト3 リトライ制御の明示的指定方法

//リトライ回数5回、累積待ち時間60秒の接続ポリシーを作成
ConnectionPolicy cp = new ConnectionPolicy();
cp.RetryOptions.MaxRetryAttemptsOnThrottledRequests = 5;
cp.RetryOptions.MaxRetryWaitTimeInSeconds = 60; 

// ConnectionPolicyを指定してDocumentClientを作成
DocumentClient client = 
  new DocumentClient(
    new Uri("https://[your account].documents.azure.com:443/"),
    "[your key]",
    cp);

//接続ポリシーが適用されたクエリー処理が実行される
var query =
  client.CreateDocumentQuery<RoomReservationInfo>(
    UriFactory.CreateDocumentCollectionUri("GroupwareDB", "ReservationCollections")).
    Where(r => r.Room == "大会議室");
var result = query.ToList();

8.2.5 一貫性レベル(Consistency Level)による消費RUの違い

一貫性レベルの設定が消費RUに影響します。
公式ドキュメントに記された情報からは、以下のような関係性であると考えられます。

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

※図の吹き出しの通り Session と Consistenc Prefix の消費RUの上下関係は未確認です(詳しい方がおられましたら、コメント or RyuichiDaigo@そら (@ryuichi111std) | Twitter までご指摘いただけたら嬉しいです)。

実際の値を確認するために、先程のリスト1を用いて、ConsistencyLevel値をそれぞれに変更してみました。
テストした結果の消費RU値は以下の通りです(絶対的数値の大きさは取得件数・データ量が影響しているので、各一貫性レベル間の相対値の比較が確認対象です)。

Strong Bounded Staleness Session Consistent Prefix Eventual
70.74 70.74 35.37 35.37 35.37

Strong / Bounded Stalenessは高い値。
Session / Consistent Prefix / Eventualが同一値となりました。Eventualが低くなってほしかったのですが、このクエリー処理においては同様のRU消費量のようでした。

※ 上記は、Strongを試すために単一リージョン構成での実行結果です。ただし、グローバルレプリケーション構成でも、同様の結果が得られました(勿論Strong以外)。

8.2.6 RU/m (RU/分) の設定

前述の通り、設定したRU/sの許容量を超えると レスポンスコード 429 のエラーが発生してしまいます。
ライブラリによるリトライ処理が行われ、クエリー処理は継続されますが、当然処理の遅延につながってしまいます。さらにリトライで追いつかない程のキャパシティ不足となった場合、例外が発生してしまいます(つまり、アプリケーションとしてはユーザーに対してタイムアウトエラーを伝えなければなりません)。

アプリケーションでは、時間帯によるトラフィックの変動や、何らかの原因による一時的なトラフィックの増加が発生することがあります。
RU/sの概念のみでこのような状況に対応しようとした場合、最大負荷状態に合わせたRU/s設定を行う必要があります。しかし、一時的な高負荷時間帯以外の時間帯では、過剰なRU/s設定となってしまいコストが増してしまいます。

そこで RU/m(RU/分) という機能が用意されています。
RU/sと同様に、コレクションに対して RU/m の設定を行います。
Azureポータルでの設定画面は以下になります(データベースアカウント → スケール)。

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

設定対象のコレクションをドロップダウンで選択し、「RU/分」項目を「オン」に設定して「保存」ボタンをクリックします。

RU/mの設定値

RU/m(RU/分)は「オン / オフ」の設定です。
RU/mは具体的な数値を設定しません。RU/sに対して10倍の値が自動的に設定されます。
コレクションに対するRU/s設定が 400 であれば、RU/mは 4,000 となります。
また、5,000RU/sまでのコレクションに対してのみ RU/m を有効化することができます。
5,001RU/s以上のスループットを設定したコレクションには RU/m を有効化することはできません。

RU/mの消費例

RU/m(RU/分)がオンに設定されている場合、あるタイミングにおいて、1秒あたりの予約RU/sを超えてしまったとします。その場合、RU/sとは別枠のRU/mが消費されデータベース操作が行われます。

以下に具体的な RU/sとRU/mの消費と回復 の例を説明します。
前提条件は「RU/s = 1,000 、 RU/mオン」とします。

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

①開始点から0.2秒後に200RUを消費する処理が実行されました。
→RU/sの残が800となりました。
②0.4秒後に300RUを消費する処理が実行されました。
→RU/sの残が500となりました。
③0.6秒後に600RUを消費する処理が実行されました。
→RU/sの残が0となりました。RU/sが不足したのでRU/mが消費され、残が9,900となりました。 ④0.9秒後に500RUを消費する処理が実行されました。
→RU/sの残が0なので、RU/mが消費され、RU/mの残が9,400となりました。
⑤1秒後にRU/sが回復しました。
→RU/sの残が1000、RU/mの残はそのまま9,400となりました。
⑥1.2秒後に350RUを消費する処理が実行されました。
→RU/sの残が650となりました。
⑦1.5秒後に800RUを消費する処理が実行されました。
→RU/sの残が0となりました。RU/mが消費され、残が9,250となりました。
⑧1.8秒後に1,500RUを消費する処理が実行されました。
→RU/sの残が0なので、RU/mが消費され、RU/mの残が7,750となりました。
⑨60秒後、RU/mは回復し10,000となります。

8.3 グローバルレプリケーション

Cosmos DBは分散型NoSQLデータベースです。世界各国のAzureリージョンに対してグローバルレプリケーションすることが可能です。
2017/7/9現在、Azureの世界各所 24個所 のリージョンにCosmos DBをレプリケーションすることが可能です。
グローバルレプリケーションを行うことのメリットは、主に以下の2点が挙げられます。

  • 耐障害性の向上
    地理的に離れたリージョンにデータベースをレプリケーションすることで、Disaster Recoveryであったり、リージョン障害時の継続稼働を可能にします。

  • 性能向上
    ユーザーが世界各国に散らばっているようなシステムにおいて、利用者に近いリージョンでデータベースを動作させることで、性能向上を図ります。(ネットワーク遅延の抑制)

8.3.1 書き込みリージョンと読み込みリージョン

Cosmos DBでは、複数のリージョンにデータベースをレプリケーションすることが可能です。
この場合、書き込みリージョンは1つ、それ以外は読み取りリージョンとなります。
つまりレプリケーションを行うことによる地理的性能向上は、読み取りに対して有効なもので、書き込みに関しては、世界で1つの書き込みリージョンに対して行うことになります。
書き込みリージョンに対して書き込まれたデータが、Cosmos DBのバックエンド処理によりレプリケーション構成された各リージョンにコピーされる仕組みとなります。

8.3.2 レプリケーションの作成

レプリケーションは、Azureポータルでマウス操作のみで簡単に行うことができます。
レプリケーションは簡単に作成できますが、レプリケーションしたリージョン毎に 割り当てRU の課金が行われますので注意してください(料金プランについては今後変更の可能性もあるので、公式情報をご確認ください)。
例えば 1,000RU/s のコレクション1つを含むCosmos DBアカウントにおいて、レプリケーションを1つ追加した場合、1,000RU/s × 2リージョン = 2,000RU/sの料金 が発生します。

Azureポータルでレプリケーションを作成する手順は、以下の通りです。

(1) Azureポータルを表示

Azureポータルにログインし、対象のCosmos DBアカウントを選択。さらに「データをグローバルにレプリケートする」メニューを選択します。
世界地図とリージョンを表すマークが表示されます。

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

(2) レプリケーションするリージョンを選択

世界地図上のマークの意味は以下の通りです。

いくつかのリージョンを選択して「保存」ボタンをクリックすると、レプリケーションが開始されます。
小さなデータベースでも、1つレプリケーションを増やすのに数分はかかると思います。ただし、レプリケーション構成中も、データベースは停止することなく、アクティブに動作し続けています。
また公式ドキュメントの記述では「100TBまでのデータであれば30分」でレプリケーション可能であるとのことです。

8.3.3 アプリケーション配置 と Cosmos DB レプリケーション

Cosmos DBはデータベースシステムなので、ユーザーに対して単体で提供するものではありません。
1つの代表的な(そしてもっとも単純な)利用想定は、以下のような形です。

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

Azure App ServiceでWebアプリケーションをホストします。
バックエンドのデータベースとしてCosmos DBを利用します。

そして、ユーザーが世界各国に散らばっていた場合、負荷分散と地理的なネットワーク遅延の問題回避のために以下のような構成をとります。

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

Azure App Serviceを世界のリージョンに分散配置すると共に、Cosmos DBも併せてApp Serviceと同一リージョン(もしくは構成によっては近くのリージョン)に配置(レプリケーション)します。

8.3.4 読み取りリージョンの明示的指定

グローバル レプリケーション設定は、Azureポータル上の操作で簡単に行えました。
しかし、プログラム側でも「どのリージョンから読み取るか」という指定をする必要があります。これを行わないと、データベース自体はグローバルレプリケーションされているが、実際にアプリケーションがアクセスするのは、マスターの書き込みリージョンのみになってしまいます。

具体的なプログラムでの設定方法は以下の通りです。

DocumentClientオブジェクトを生成する際に指定可能な、ConnectionPolicyオブジェクトの「PreferredLocationsプロパティ」を利用します。
ConnectionPolicy.PreferredLocationsプロパティは、「Collection型」で複数のリージョンを優先順位順に設定することができます。
以下のリスト4は「WestEurope」「BrazilSouth」の優先順位を指定してクエリーを行います。
WestEuropeが読み取りできない状態であった場合、BrazilSouthkから読み取りが行われます。WestEurope / BrazilSouthのどちらのリージョンからも読み取れない状況であった場合は、書き込みリージョン(マスターリージョン)からの読み取りが行われます。

リスト4 リージョンを指定して読み取り

// リージョン名はstring型で指定しますが「LocationNames列挙体」が用意されています。
ConnectionPolicy connectionPolicy = new ConnectionPolicy();
connectionPolicy.PreferredLocations.Add(LocationNames.WestEurope);
connectionPolicy.PreferredLocations.Add(LocationNames.BrazilSouth);
connectionPolicy.ConnectionMode = ConnectionMode.Gateway;
connectionPolicy.ConnectionProtocol = Protocol.Https;

DocumentClient client = 
  new DocumentClient(
    new Uri("https://[your account].documents.azure.com:443/"),
    "[your key]",
    connectionPolicy);

var query =
  this.client.CreateDocumentQuery<RoomReservationInfo>(
    UriFactory.CreateDocumentCollectionUri("GroupwareDB", "RoomReservations"))
    .Where(r => r.Room == "大会議室");
var result = query.ToList();

読み取りリージョンを確認する

論理的なお話よりも実際の動きを検証した方が、しっくりと頭に入ってくると思います。
ということで、PreferredLocations指定時のHTTP通信内容をFiddlerで確認してみたいと思います。
前提条件は以下とします。

  • 書き込みリージョン(マスター)は Japan West です
  • レプリケーションされた読み取りリージョンは「WestEurope」と「BrazilSouth」です

実行するコードは、先程のリスト4です。
Fiddlerを確認すると、3つのHTTPS通信が行われています。

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

①「/」へのGET
まず、「https://cosmosdoc.documents.azure.com/」へのGETリクエストを行っています。
特定リージョンではなく「https://cosmosdoc.documents.azure.com/」に対してのGETとなります。
以下にRequest / Reponseのトレースをつらつらと張りつけますが、Response Body内のJSONで「writableLocations / readableLocations」という項目があります。
それぞれ「書き込みリージョン情報」および「読み取りリージョン情報」となります。
これによりDocumentDBライブラリは接続先データベースのマスター及びレプリケーションリージョンの構成を把握します。

--- 1つ目のHTTP Request(/) ---

GET https://cosmosdoc.documents.azure.com/ HTTP/1.1
x-ms-version: 2017-02-22
User-Agent: documentdb-dotnet-sdk/1.14.1 Host/32-bit MicrosoftWindowsNT/6.2.9200.0
x-ms-date: Sat, 08 Jul 2017 13:51:10 GMT
authorization: type%3dmaster%26ver%3d1.0%26sig%3dEYpxSo1lqVjtGwbUoMVFp2eZLPlR8OeC06L5WcsTtEg%3d
Host: cosmosdoc.documents.azure.com
Connection: Keep-Alive


--- 1つ目のHTTP Request(/)に対するResponse ---
HTTP/1.1 200 Ok
Cache-Control: no-store, no-cache
Pragma: no-cache
Transfer-Encoding: chunked
Content-Type: application/json
Content-Location: https://cosmosdoc.documents.azure.com/
Server: Microsoft-HTTPAPI/2.0
x-ms-max-media-storage-usage-mb: 2048
x-ms-media-storage-usage-mb: 0
x-ms-databaseaccount-consumed-mb: 0
x-ms-databaseaccount-reserved-mb: 0
x-ms-databaseaccount-provisioned-mb: 0
Strict-Transport-Security: max-age=31536000
x-ms-gatewayversion: version=1.14.33.2
Date: Sat, 08 Jul 2017 13:51:09 GMT

5B7
{  
  "_self":"",
  "id":"cosmosdoc",
  "_rid":"cosmosdoc.documents.azure.com",
  "media":"//media/",
  "addresses":"//addresses/",
  "_dbs":"//dbs/",
  "writableLocations":[  
    {  
      "name":"Japan West",
      "databaseAccountEndpoint":"https://cosmosdoc-japanwest.documents.azure.com:443/"
    }
  ],
  "readableLocations":[  
    {  
      "name":"Japan West",
      "databaseAccountEndpoint":"https://cosmosdoc-japanwest.documents.azure.com:443/"
    },
    {  
      "name":"Brazil South",
      "databaseAccountEndpoint":"https://cosmosdoc-brazilsouth.documents.azure.com:443/"
    },
    {  
      "name":"West Europe",
      "databaseAccountEndpoint":"https://cosmosdoc-westeurope.documents.azure.com:443/"
    }
  ],
  "userReplicationPolicy":{  
    "asyncReplication":false,
    "minReplicaSetSize":3,
    "maxReplicasetSize":4
  },
  "userConsistencyPolicy":{  
    "defaultConsistencyLevel":"BoundedStaleness"
  },
  "systemReplicationPolicy":{  
    "minReplicaSetSize":3,
    "maxReplicasetSize":4
  },
  "readPolicy":{  
    "primaryReadCoefficient":1,
    "secondaryReadCoefficient":1
  },
  "queryEngineConfiguration":"{\"maxSqlQueryInputLength\":30720,\"maxJoinsPerSqlQuery\":5,\"maxLogicalAndPerSqlQuery\":500,\"maxLogicalOrPerSqlQuery\":500,\"maxUdfRefPerSqlQuery\":2,\"maxInExpressionItemsCount\":8000,\"queryMaxInMemorySortDocumentCount\":500,\"maxQueryRequestTimeoutFraction\":0.9,\"sqlAllowNonFiniteNumbers\":false,\"sqlAllowAggregateFunctions\":true,\"sqlAllowSubQuery\":false,\"allowNewKeywords\":true,\"sqlAllowLike\":false,\"maxSpatialQueryCells\":12,\"spatialMaxGeometryPointCount\":256,\"sqlAllowTop\":true,\"enableSpatialIndexing\":true}"
}

②コレクション情報取得
PreferredLocationsで第1優先先として指定した「WestEurope」に対してのGETリクエストとなります(https://cosmosdoc-westeurope.documents.azure.com/dbs/GroupwareDB/colls/RoomReservations)。

--- 2つ目のHTTP Request ---

GET https://cosmosdoc-westeurope.documents.azure.com/dbs/GroupwareDB/colls/RoomReservations HTTP/1.1
x-ms-date: Sat, 08 Jul 2017 13:51:10 GMT
authorization: type%3dmaster%26ver%3d1.0%26sig%3dkTjmw8Dl1qxV8Boc6tjAEgxvV5K32FIYxENUN7CHdOQ%3d
Cache-Control: no-cache
x-ms-consistency-level: BoundedStaleness
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-westeurope.documents.azure.com



--- 2つ目のHTTP Requestに対するResponse ---

HTTP/1.1 200 Ok
Cache-Control: no-store, no-cache
Pragma: no-cache
Transfer-Encoding: chunked
Content-Type: application/json
Content-Location: https://cosmosdoc-westeurope.documents.azure.com/dbs/GroupwareDB/colls/RoomReservations
Server: Microsoft-HTTPAPI/2.0
Strict-Transport-Security: max-age=31536000
x-ms-last-state-change-utc: Tue, 04 Jul 2017 00:10:13.508 GMT
etag: "00007800-0000-0000-0000-5960908e0000"
collection-partition-index: 0
collection-service-index: 0
x-ms-schemaversion: 1.3
x-ms-alt-content-path: dbs/GroupwareDB
x-ms-content-path: -2kXAA==
x-ms-xp-role: 1
x-ms-request-charge: 2
x-ms-serviceversion: version=1.14.33.2
x-ms-activity-id: 4bdac467-4bfd-4710-a3c8-457a154e746a
x-ms-session-token: 0:1539
x-ms-documentdb-collection-index-transformation-progress: -1
x-ms-gatewayversion: version=1.14.33.2
Date: Sat, 08 Jul 2017 13:51:12 GMT

225
{
  "id":"RoomReservations",
  "indexingPolicy":{
    "indexingMode":"consistent",
    "automatic":true,
    "includedPaths":
    [
      {
        "path":"\/*",
        "indexes":[
          {"kind":"Range","dataType":"Number","precision":-1},
          {"kind":"Hash","dataType":"String","precision":3}
        ]
      }
    ],
    "excludedPaths":[]
  },
  "partitionKey":{"paths":["\/Room"],"kind":"Hash"},
  "_rid":"-2kXAO4IMQA=",
  "_ts":1499500658,
  "_self":"dbs\/-2kXAA==\/colls\/-2kXAO4IMQA=\/",
  "_etag":"\"00007800-0000-0000-0000-5960908e0000\"",
  "_docs":"docs\/",
  "_sprocs":"sprocs\/",
  "_triggers":"triggers\/",
  "_udfs":"udfs\/",
  "_conflicts":"conflicts\/"
}
0

③ WestEuropeリージョンに対してクエリー処理が行われます。
https://cosmosdoc-westeurope.documents.azure.com/dbs/GroupwareDB/colls/RoomReservations/docs

--- 3つ目のHTTP Request ---

POST https://cosmosdoc-westeurope.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, 08 Jul 2017 13:51:12 GMT
authorization: type%3dmaster%26ver%3d1.0%26sig%3dod2h8xICdwZh0fqdvSt1Dl6OUZW19p9qHRZVXlOTwp8%3d
Cache-Control: no-cache
x-ms-consistency-level: BoundedStaleness
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-westeurope.documents.azure.com
Content-Length: 98
Expect: 100-continue

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


--- 3つ目のHTTP Requestに対するResponse ---

HTTP/1.1 200 Ok
Cache-Control: no-store, no-cache
Pragma: no-cache
Transfer-Encoding: chunked
Content-Type: application/json
Server: Microsoft-HTTPAPI/2.0
Strict-Transport-Security: max-age=31536000
x-ms-last-state-change-utc: Tue, 04 Jul 2017 00:10:13.881 GMT
x-ms-resource-quota: documentSize=10240;documentsSize=10485760;documentsCount=-1;collectionSize=10485760;
x-ms-resource-usage: documentSize=1;documentsSize=859;documentsCount=1164;collectionSize=1152;
x-ms-item-count: 97
x-ms-schemaversion: 1.3
x-ms-alt-content-path: dbs/GroupwareDB/colls/RoomReservations
x-ms-content-path: -2kXAO4IMQA=
x-ms-xp-role: 2
x-ms-request-charge: 70.74
x-ms-serviceversion: version=1.14.33.2
x-ms-activity-id: 25dd501b-ff38-49d5-aea4-e8946e204445
x-ms-session-token: 0:1539
x-ms-gatewayversion: version=1.14.33.2
Date: Sat, 08 Jul 2017 13:51:14 GMT

E093
{"_rid":"-2kXAO4IMQA=","Documents":[{"id":"0000000004","Room":"スタンディングテーブル","Title":"アーキテクチャ社内勉強会(06\/02回)","ReservedUserId":"kubota","ReservedUserName":"久保田元也","Start":"2017-06-02T13:00:00.0000000",
...省略...
"_attachments":"attachments\/","_ts":1499500709}],"_count":97}
0

上記トレース情報から、ライブラリがリージョンを確認し、その上で優先指定されたリージョンに対してクエリー操作を発行している様子を確認することができます。

【補足】

つい最近 三宅@ZEN さんも、この点についての投稿を行われているので、そちらも参考になります。
k-miyake.github.io

8.4 障害復旧およびフェールオーバー

グローバルレプリケーションを設定していると、特定リージョンで障害が発生した際にフェールオーバーによる障害復旧を行うことが出来ます。
もしくは、障害とは関係なく手動でのフェールオーバーも可能です。
フェールオーバーには「自動」と「手動」の2つの方法が用意されています。

8.4.1 自動フェールオーバー

自動フェールオーバーについて、「読み取りリージョンが停止した場合」「書き込みリージョンが停止した場合」の、2つのケースについて以下に説明します。

(1) 読み取りリージョンが停止した場合

書き込み(マスター)リージョンは正常稼働状態で、読み取りリージョンが停止した場合の動作について説明します。
データベース操作ロジックにおいて、PreferredLocationsで指定された優先リージョンのうち、有効なリージョンに自動的に接続します。PreferredLocationsで指定されたリージョンがすべて停止している場合は、書き込みリージョンにアクセスします。

具体的な例は以下の通りです。
前提は・・・

  • JapanEastが書き込みリージョン
  • JapanWestおよびBrazilSouthがレプリケーションされた読み取りリージョン
  • 操作ロジックのPreferredLocations設定はリスト5の通り

この時、BrazilSouthが停止した場合には、JapanWestから読み取りが行われます。
BrazilSouthに加えてJapanEastも停止してしまっていた場合には、書き込みリージョンであるJapanEastから読み取りが行われます。

リスト5 
ConnectionPolicy connectionPolicy = new ConnectionPolicy();
connectionPolicy.PreferredLocations.Add(LocationNames.BrazilSouth);
connectionPolicy.PreferredLocations.Add(LocationNames.JapanWest);

(2) 書き込みリージョンが停止した場合

書き込みリージョンが停止した場合の自動フェールオーバーは、「自動フェールオーバーの有効化」が、オンになっている必要があります。
Azureポータル上で「データをグローバルにレプリケートする」→「自動フェールオーバー」で設定を行うことができます。

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

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

書き込みリージョンが停止した場合、書き込みリージョンのデータベースは、自動的にオフラインモードになります。
更に優先度上位の読み込みリージョンが、書き込みリージョンに昇格します。
元々の書き込みリージョンが復旧したら、データのマージ及び競合の解消を行い、手動フェールオーバーにより書き込みリージョンに再度昇格させます。

※書き込みリージョンの障害からのフェールオーバーについては、私自身実際の操作経験がない為、公式ドキュメントからの受け売りですm(_ _)m

8.4.2 手動フェールオーバー

手動フェールオーバーは、Azureポータルもしくはプログラムから行うことができます。
そして、書き込みリージョンの変更(フェールオーバー)処理において、「データ ロス 0」「アベイラビリティ ロス 0」が保証されています。

Azureポータルでの操作手順

Azureポータルを表示し、「データをグローバルにレプリケートする」メニューを選択します。 「手動フェールオーバー」メニューを選択します。

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

書き込みリージョンに変更(昇格)する読み取りリージョンを選択する画面が表示されます。
書き込みリージョンに昇格させる読み取りリージョンを選択してOKをクリックします。

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

フェールオーバー処理にしばらくの時間がかかりますが、以上で手動による「書き込みリージョンのフェールオーバー」が完了です。前述の通り、稼働状態でこの作業を行うことが可能です。
以下が手動フェールオーバーが完了した画面です。選択したWest Europeが書き込みリージョンに昇格しています(私の環境では、ブラウザをリロードしないと表示が反映されませんでした)。

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

Cosmos DB公式サイトにおいて、手動フェールオーバーの利用目的の1つとして以下のようなことが挙げられていました。

Follow the clock model: If your applications have predictable traffic patterns based on the time of the day, you can periodically change the write status to the most active geographic region based on time of the day.

つまり・・・
1日の中で時間帯による書き込みトラフィックのパターンが予測できる場合、時間毎に最もアクティブなリージョンに書き込みリージョンをフェールオーバーする。

個人的には、何かあった時、計画されたアプリケーションアップデートの際、に使用するものかと思っていましたが、上記のような運用上の仕組みとして書き込みリージョンの手動フェールオーバーを行っても良いようです。
実運用適用には、いろいろ試してから適用したいですね・・・。

8.5 まとめ

「Cosmos DB入門」と題して全8回にわたって投稿を行いました。
Cosmos DB、つまりNoSQLは、すべてのリレーショナルデータベースに置き換わるものではありません。
SQL ServerSQL Database(PaaS)は引き続き利用され続けるでしょうし、それは正しいことです。一方、クラウドネイティブなシステムにおいて、Cosmos DBの適用が、よりスケーラブル で よりローレイテンシー を実現する肝となることもあると考えます。
本格的にNoSQLを導入する場合、トランザクションやデータの一貫性といった点だけでも、従来のRDBの感覚とは全く異なる、NoSQLならではのコツが必要になってきます。むしろ多くの苦労を要する事も多々あることでしょう。同時に、それと引き換えに得られるメリットは十分にあるとも考えます。
本連載では、やはり「入門」というレベルに留まった内容であり、本番運用のシステムに適用するにはより多くの知識が必要になると考えています。
まずは、Cosmos DBの概要から設定・操作・コーディング手法の基本を学ぶきっかけになっていただけたならうれしく思います。
私個人としては、今後も本連載以外のトピックや、私自身の知識とノウハウの積み重ねにより更に踏み込んだ面白い投稿をしていけたらと思っています。
ということで、長文投稿ばかりの「Cosmos DB入門」を読んでいただいた皆様、ありがとうございました!

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