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